diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 85ed5cd3b..a1336cdab 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -14,5 +14,15 @@ reviews: - "!**/*.ts" - "!**/*.js" - "!**/*.svg" + pre_merge_checks: + custom_checks: + - name: "No attribution trailers" + mode: error + instructions: >- + Fail when the PR description or any commit message carries an + attribution trailer or footer: Co-Authored-By, Claude-Session, + Generated-By, or a "Generated with"/"Generated by" tool line. + Contributors own their contributions (AGENTS.md); ask for the + lines to be removed. chat: auto_reply: true diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 0661e0c71..9b6a0edfd 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25-bookworm +FROM golang:1.26.7-bookworm RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ && apt-get -y install --no-install-recommends\ diff --git a/.git-branches.toml b/.git-branches.toml index d1818090f..4c34d7928 100644 --- a/.git-branches.toml +++ b/.git-branches.toml @@ -3,7 +3,7 @@ [branches] main = "main" perennials = [] -perennial-regex = "" +perennial-regex = "^release-" [create] new-branch-type = "feature" diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 000000000..e5699bd9c --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,26 @@ +#!/bin/bash +# Refuses commit messages that carry attribution trailers. Contributors own +# their contributions (AGENTS.md, "No Co-Authored-By or tool-attribution +# trailers"); a trailer spreads that ownership onto a tool or a bystander. + +msg_file="$1" + +# Trailer keys in any casing, with any bullet or emoji in front. +trailers='^[^[:alnum:]]*(co-authored-by|claude-session|generated-by):' +# "Generated with/by" footers, including "Generated with by". +footer='^[^[:alnum:]]*generated (with|by)( [^[:alnum:]]*by)? ' +# A footer names a product, so a capitalized word must follow the phrase +# itself. Prose such as "generated by the protobuf compiler" stays legal. +tool='[Gg][Ee][Nn][Ee][Rr][Aa][Tt][Ee][Dd] ([Ww][Ii][Tt][Hh]|[Bb][Yy])( [^[:alnum:]]*[Bb][Yy])? [^[:alnum:]]*[A-Z]' + +offending=$( { + grep -Ein "$trailers" "$msg_file" + grep -Ein "$footer" "$msg_file" | grep -E "$tool" +} | sort -un ) + +if [ -n "$offending" ]; then + echo "commit-msg: attribution trailers are not accepted in this repository:" >&2 + printf '%s\n' "$offending" | sed 's/^/ /' >&2 + echo "Remove them and commit again (see AGENTS.md)." >&2 + exit 1 +fi diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b78b1417a..647e04936 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,8 +3,8 @@ updates: - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "daily" - open-pull-requests-limit: 15 + interval: "weekly" + open-pull-requests-limit: 3 groups: actions: patterns: @@ -22,9 +22,12 @@ updates: directories: - "/" schedule: - interval: "daily" + interval: "weekly" open-pull-requests-limit: 15 groups: + golang-x-packages: + patterns: + - "golang.org/x/*" aws-sdk: patterns: - "github.com/aws/aws-sdk-go-v2/*" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 8e68054bd..9b796f262 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,6 +2,12 @@ ## Issue ticket number and link + + ## Stack @@ -12,7 +18,9 @@ - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) -- [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). +- [ ] I ran and tested this change locally — I did not rely on CI to find out whether it works +- [ ] This PR has a single purpose (not a fix + refactor + feature in one) +- [ ] This change is a trivial fix, **OR** it links an issue the NetBird team agreed on beforehand. Changes to the public API, gRPC protocols, functionality behavior, CLI / service flags, or new features always need that agreement first. See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). diff --git a/.github/workflows/agent-network-e2e.yml b/.github/workflows/agent-network-e2e.yml index d78e3bbd3..9501c5fba 100644 --- a/.github/workflows/agent-network-e2e.yml +++ b/.github/workflows/agent-network-e2e.yml @@ -5,6 +5,20 @@ on: schedule: - cron: "0 3 * * *" workflow_dispatch: + inputs: + bedrock_model: + description: >- + Bedrock inference-profile id to drive the matrix with, exactly as + AWS issues it. Leave empty for the Sonnet 4.6 default. + required: false + default: "" + test_pattern: + description: >- + Package pattern to run. Defaults to the whole suite; narrow it to one + package (e.g. ./e2e/agentnetwork/...) when a run only needs that + package's answer and not the sixteen minutes the container suite costs. + required: false + default: "./e2e/..." concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -51,6 +65,9 @@ jobs: # token (and URL, for gateways) is unset, so partial coverage is fine. OPENAI_TOKEN: ${{ secrets.E2E_OPENAI_TOKEN }} ANTHROPIC_TOKEN: ${{ secrets.E2E_ANTHROPIC_TOKEN }} + # Moonshot AI platform key (platform.kimi.ai); drives both Kimi wire + # shapes (OpenAI /v1 and Anthropic /anthropic) through kimi_api. + KIMI_TOKEN: ${{ secrets.E2E_KIMI_TOKEN }} VERCEL_URL: ${{ secrets.E2E_VERCEL_URL }} VERCEL_TOKEN: ${{ secrets.E2E_VERCEL_TOKEN }} OPENROUTER_URL: ${{ secrets.E2E_OPENROUTER_URL }} @@ -59,10 +76,16 @@ jobs: CLOUDFLARE_TOKEN: ${{ secrets.E2E_CLOUDFLARE_TOKEN }} AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.E2E_AWS_BEARER_TOKEN_BEDROCK }} AWS_REGION: ${{ secrets.E2E_AWS_REGION }} + # Bedrock model override: dispatch input wins, then the repo variable, else the test default. + AWS_BEDROCK_MODEL: ${{ inputs.bedrock_model || vars.E2E_AWS_BEDROCK_MODEL }} # Vertex (Anthropic-on-Vertex): SA + project required; region defaults # to "global", model to a pinned claude snapshot. GOOGLE_VERTEX_SA_BASE64: ${{ secrets.E2E_GOOGLE_VERTEX_SA_BASE64 }} GOOGLE_VERTEX_PROJECT: ${{ secrets.E2E_GOOGLE_VERTEX_PROJECT }} GOOGLE_VERTEX_REGION: ${{ secrets.E2E_GOOGLE_VERTEX_REGION }} GOOGLE_VERTEX_MODEL: ${{ secrets.E2E_GOOGLE_VERTEX_MODEL }} - run: go test -tags e2e -timeout 40m -v ./e2e/... + # Read through an env var rather than interpolated into the run + # script: a dispatch input reaching a shell command directly is a + # script-injection seam, however trusted the dispatcher. + TEST_PATTERN: ${{ inputs.test_pattern || './e2e/...' }} + run: go test -tags e2e -timeout 40m -v "$TEST_PATTERN" diff --git a/.github/workflows/buf.yml b/.github/workflows/buf.yml new file mode 100644 index 000000000..cc7629534 --- /dev/null +++ b/.github/workflows/buf.yml @@ -0,0 +1,48 @@ +name: protobuf checks +on: + push: + branches: + - main + - "release-*" + pull_request: + paths: + - ".github/workflows/buf.yml" + - "**/buf.yaml" + - "**/buf.lock" + - "**/buf.gen.yaml" + - "**.proto" +permissions: + contents: read + pull-requests: read +jobs: + buf: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: bufbuild/buf-action@8c6a16e16f12ba20b6470afa9c2ba9b5ba8c97c3 # v1.5.0 + with: + push: false + archive: false + pr_comment: false + lint: false + format: false + # A push that creates a branch carries no `before` commit, so the + # action's default baseline is the all-zero SHA and `buf breaking` + # dies cloning it. Skipping costs nothing: every commit on a freshly + # cut release branch should have already passed this check on main. + breaking: ${{ !github.event.created }} + # The alternative is to compare against the default branch instead of + # skipping. Not used: buf clones the baseline when the job runs, so a + # main that has moved on since the branch was cut reads as protos + # deleted on the release branch. Resolving to an empty string on every + # other event is what keeps the action's own default in place, which + # stacked pull requests need. + # breaking_against: >- + # ${{ github.event.created + # && format('{0}#format=git,branch={1}', + # github.event.repository.clone_url, + # github.event.repository.default_branch) + # || '' }} diff --git a/.github/workflows/check-license-dependencies.yml b/.github/workflows/check-license-dependencies.yml index 17c9fdc8d..81d293e4f 100644 --- a/.github/workflows/check-license-dependencies.yml +++ b/.github/workflows/check-license-dependencies.yml @@ -2,7 +2,7 @@ name: Check License Dependencies on: push: - branches: [main] + branches: [main, "release-*"] paths: - "go.mod" - "go.sum" diff --git a/.github/workflows/frontend-ui.yml b/.github/workflows/frontend-ui.yml index bb5bb4528..014c5c2ae 100644 --- a/.github/workflows/frontend-ui.yml +++ b/.github/workflows/frontend-ui.yml @@ -10,6 +10,7 @@ on: push: branches: - main + - "release-*" paths: - "client/ui/frontend/**" - "client/ui/i18n/**" @@ -86,7 +87,7 @@ jobs: ${{ runner.os }}-pnpm- - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile --ignore-scripts - name: Generate Wails bindings run: pnpm run bindings diff --git a/.github/workflows/golang-test-darwin.yml b/.github/workflows/golang-test-darwin.yml index 420749a0e..dec57dc80 100644 --- a/.github/workflows/golang-test-darwin.yml +++ b/.github/workflows/golang-test-darwin.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - "release-*" pull_request: concurrency: @@ -45,15 +46,17 @@ jobs: run: git --no-pager diff --exit-code - name: Test - # Exclude client/ui: its main.go uses //go:embed all:frontend/dist, - # which fails to compile until the frontend has been built. The Wails UI - # has no Go-side unit tests, and its release pipeline runs `pnpm build` - # before goreleaser. + # Exclude the client/ui package itself: its main.go uses //go:embed + # all:frontend/dist, which fails to compile until the frontend has been + # built, and its release pipeline runs `pnpm build` before goreleaser. + # The pattern is anchored so the subpackages (services, preferences, + # i18n, authsession) still run: they hold Go-side unit tests and need no + # frontend bundle. # `go list -e` lets the listing succeed even though the embed fails to # resolve; the grep then drops the broken package by path. Without -e, # go list aborts with empty stdout and `go test` falls back to the repo # root, which has no Go files. - run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags 'devcert privileged' -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /client/testutil/privileged) + run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags 'devcert privileged' -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e '/client/ui$' -e /client/testutil/privileged) - name: Upload coverage reports to Codecov uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0 diff --git a/.github/workflows/golang-test-freebsd.yml b/.github/workflows/golang-test-freebsd.yml index 9c795e783..65c39147a 100644 --- a/.github/workflows/golang-test-freebsd.yml +++ b/.github/workflows/golang-test-freebsd.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - "release-*" pull_request: concurrency: diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml index 0af506bba..449eb14fa 100644 --- a/.github/workflows/golang-test-linux.yml +++ b/.github/workflows/golang-test-linux.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - "release-*" pull_request: concurrency: @@ -159,9 +160,10 @@ jobs: - name: Test # Exclude client/ui: its main.go uses //go:embed all:frontend/dist, - # which fails to compile until the frontend has been built. The Wails UI - # has no Go-side unit tests, and its release pipeline runs `pnpm build` - # before goreleaser. + # which fails to compile until the frontend has been built, and its + # release pipeline runs `pnpm build` before goreleaser. The subpackages + # go with it because this runner's gtk4 is older than the wails runtime + # needs; the Client UI / Unit job below covers them instead. # `go list -e` lets the listing succeed even though the embed fails to # resolve; the grep then drops the broken package by path. Without -e, # go list aborts with empty stdout and `go test` falls back to the repo @@ -176,6 +178,35 @@ jobs: slug: netbirdio/netbird flags: unit,client + test_client_ui: + name: "Client UI / Unit" + # Pinned to 24.04 rather than the 22.04 the other client jobs use: the wails + # runtime's linux cgo layer needs GtkFileDialog, which arrived in gtk4 4.10, + # and jammy ships 4.6. Not ubuntu-latest, so a runner image rollover cannot + # move this out from under us. + runs-on: ubuntu-24.04 + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Install Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: "go.mod" + cache: false + + - name: Install dependencies + run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev + + - name: Test + # client/ui itself stays out: its main.go embeds all:frontend/dist, + # which only exists after `pnpm build`. The subpackages carry the + # Go-side unit tests, including the window manager re-entrancy + # regression test, and need no frontend bundle. + run: CGO_ENABLED=1 go test -timeout 5m ./client/ui/authsession/... ./client/ui/i18n/... ./client/ui/preferences/... ./client/ui/services/... + test_client_on_docker: name: "Client (Docker) / Unit" needs: [build-cache] @@ -210,6 +241,9 @@ jobs: ${{ runner.os }}-gotest-cache- - name: Run tests in container + # Unlike the native job above, this one drops all of client/ui including + # the subpackages: the alpine container has no gtk4/webkitgtk, so the + # Wails application package they import would fail to link. env: HOST_GOCACHE: ${{ steps.go-env.outputs.cache_dir }} HOST_GOMODCACHE: ${{ steps.go-env.outputs.modcache_dir }} @@ -232,7 +266,7 @@ jobs: -e GOCACHE=${CONTAINER_GOCACHE} \ -e GOMODCACHE=${CONTAINER_GOMODCACHE} \ -e CONTAINER=${CONTAINER} \ - golang:1.25-alpine \ + golang:1.26.7-alpine \ sh -c ' \ apk update; apk add --no-cache \ ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \ @@ -480,14 +514,32 @@ jobs: if: matrix.store == 'mysql' run: docker pull mlsmaycon/warmed-mysql:8 + # The -json stream goes through tools/gotestsummary so the log shows one + # line per test, the output of failed tests, the head of a timeout panic + # with the still-running tests, and the slowest tests per package. - name: Test + shell: bash run: | + set -o pipefail CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \ NETBIRD_STORE_ENGINE=${{ matrix.store }} \ CI=true \ - go test -tags=devcert -coverprofile=coverage.txt \ + go test -json -tags=devcert -coverprofile=coverage.txt \ -exec "sudo --preserve-env=CI,NETBIRD_STORE_ENGINE" \ - -timeout 20m ./management/... ./shared/management/... + -timeout 20m ./management/... ./shared/management/... \ + | tee management-test-events.jsonl \ + | go run ./tools/gotestsummary + + # The summary trims long outputs; the raw stream keeps every line for + # the failures that need it. A green run has no use for it. + - name: Upload raw test events + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1 + with: + name: management-unit-test-events-${{ matrix.store }} + path: management-test-events.jsonl + if-no-files-found: ignore + retention-days: 14 - name: Upload coverage reports to Codecov if: matrix.arch == 'amd64' @@ -729,17 +781,35 @@ jobs: - name: Install modules run: go mod tidy + - name: Run Mage + uses: magefile/mage-action@a662bd8c29d8106879588cfff83b2faf6e6f59db # v4.0.0 + with: + install-only: true + - name: check git status run: git --no-pager diff --exit-code + # Same summary as the unit job: a timeout here names the tests still + # running instead of ending in a goroutine dump. - name: Test + shell: bash run: | + set -o pipefail CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \ NETBIRD_STORE_ENGINE=${{ matrix.store }} \ CI=true \ - go test -tags=integration -coverprofile=coverage.txt \ - -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' \ - -timeout 20m ./management/server/http/... + mage integrationtest:all -gotestflags="-json -coverprofile=coverage.txt" \ + | tee management-integration-test-events.jsonl \ + | go run ./tools/gotestsummary + + - name: Upload raw test events + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1 + with: + name: management-integration-test-events-${{ matrix.store }} + path: management-integration-test-events.jsonl + if-no-files-found: ignore + retention-days: 14 - name: Upload coverage reports to Codecov if: matrix.arch == 'amd64' diff --git a/.github/workflows/golang-test-windows.yml b/.github/workflows/golang-test-windows.yml index 50a5ba4d6..ca300f7df 100644 --- a/.github/workflows/golang-test-windows.yml +++ b/.github/workflows/golang-test-windows.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - "release-*" pull_request: env: @@ -65,15 +66,17 @@ jobs: - run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOCACHE=${{ env.modcache }} - run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe mod tidy - name: Generate test script - # Exclude client/ui: its main.go uses //go:embed all:frontend/dist, - # which fails to compile until the frontend has been built. The Wails UI - # has no Go-side unit tests, and its release pipeline runs `pnpm build` - # before goreleaser. + # Exclude the client/ui package itself: its main.go uses //go:embed + # all:frontend/dist, which fails to compile until the frontend has been + # built, and its release pipeline runs `pnpm build` before goreleaser. + # The pattern is anchored so the subpackages (services, preferences, + # i18n, authsession) still run: they hold Go-side unit tests and need no + # frontend bundle. # `go list -e` lets the listing succeed even though the embed fails to # resolve; the Where-Object pipeline then drops the broken package by # path. Without -e, go list aborts with empty stdout. run: | - $packages = go list -e ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } | Where-Object { $_ -notmatch '/client/ui' } + $packages = go list -e ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } | Where-Object { $_ -notmatch '/client/ui$' } $goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe" $cmd = "$goExe test -tags `"devcert privileged`" -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1" Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index b444a9900..586e1235b 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -45,7 +45,7 @@ jobs: display_name: Linux name: ${{ matrix.display_name }} runs-on: ${{ matrix.os }} - timeout-minutes: 15 + timeout-minutes: 25 steps: - name: Checkout code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -79,4 +79,4 @@ jobs: skip-cache: true skip-save-cache: true cache-invalidation-interval: 0 - args: --timeout=12m + args: --timeout=20m diff --git a/.github/workflows/install-script-test.yml b/.github/workflows/install-script-test.yml index 1514caedc..61709501c 100644 --- a/.github/workflows/install-script-test.yml +++ b/.github/workflows/install-script-test.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - "release-*" pull_request: paths: - "release_files/install.sh" diff --git a/.github/workflows/mobile-build-validation.yml b/.github/workflows/mobile-build-validation.yml deleted file mode 100644 index 44e912c73..000000000 --- a/.github/workflows/mobile-build-validation.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: Mobile - -on: - push: - branches: - - main - pull_request: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }} - cancel-in-progress: true - -jobs: - android_build: - name: "Android / Build" - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - - name: Install Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 - with: - go-version-file: "go.mod" - - name: Setup Android SDK - uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 - with: - cmdline-tools-version: 8512546 - - name: Setup Java - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 - with: - java-version: "11" - distribution: "adopt" - - name: NDK Cache - id: ndk-cache - uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 - with: - path: /usr/local/lib/android/sdk/ndk - key: ndk-cache-23.1.7779620 - - name: Setup NDK - run: /usr/local/lib/android/sdk/cmdline-tools/7.0/bin/sdkmanager --install "ndk;23.1.7779620" - - name: install gomobile - run: go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20251113184115-a159579294ab - - name: gomobile init - run: gomobile init - - name: build android netbird lib - run: PATH=$PATH:$(go env GOPATH) gomobile bind -o $GITHUB_WORKSPACE/netbird.aar -javapkg=io.netbird.gomobile -ldflags="-checklinkname=0 -X golang.zx2c4.com/wireguard/ipc.socketDirectory=/data/data/io.netbird.client/cache/wireguard -X github.com/netbirdio/netbird/version.version=buildtest" $GITHUB_WORKSPACE/client/android - env: - CGO_ENABLED: 0 - ANDROID_NDK_HOME: /usr/local/lib/android/sdk/ndk/23.1.7779620 - ios_build: - name: "iOS / Build" - runs-on: macos-latest - steps: - - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - - name: Install Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 - with: - go-version-file: "go.mod" - - name: install gomobile - run: go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20251113184115-a159579294ab - - name: gomobile init - run: gomobile init - - name: build iOS netbird lib - run: PATH=$PATH:$(go env GOPATH) gomobile bind -target=ios -bundleid=io.netbird.framework -ldflags="-X github.com/netbirdio/netbird/version.version=buildtest" -o ./NetBirdSDK.xcframework ./client/ios/NetBirdSDK - env: - CGO_ENABLED: 0 diff --git a/.github/workflows/no-new-replace.yml b/.github/workflows/no-new-replace.yml new file mode 100644 index 000000000..b906ce450 --- /dev/null +++ b/.github/workflows/no-new-replace.yml @@ -0,0 +1,78 @@ +name: No New Replace Directives + +on: + pull_request: + paths: + - "go.mod" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }} + cancel-in-progress: true + +jobs: + check-replace-directives: + name: check-replace-directives + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Install Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: go.mod + + - name: Compare replace directives against the base branch + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + + # A replace directive only applies when this module is the main + # module. Anything importing netbird as a library, the embedded + # clients among them, resolves the replaced path upstream instead and + # fails to build against whatever the replacement provides. Requiring + # a fork under its own module path avoids that; a replace does not. + # + # go.mod is parsed rather than diffed so that reordering, comments and + # single-line versus block syntax do not register as changes. + # + # Versions are part of the key because a replace can be scoped to one + # version of a module. Keyed on paths alone, retargeting such a + # directive at a different version would read as unchanged. + list_replaces() { + go mod edit -json "$1" \ + | jq -r ' + def ref: .Path + (if (.Version // "") == "" then "" else " " + .Version end); + (.Replace // [])[] | "\(.Old | ref) => \(.New | ref)" + ' \ + | sort + } + + git show "${BASE_SHA}:go.mod" > /tmp/base-go.mod + list_replaces /tmp/base-go.mod > /tmp/base-replaces + list_replaces go.mod > /tmp/head-replaces + + added=$(comm -13 /tmp/base-replaces /tmp/head-replaces) + if [ -n "$added" ]; then + echo "::error::This PR adds a replace directive to go.mod:" + echo "$added" | sed 's/^/ /' + echo "" + echo "A replace directive applies only to the main module, so it does not" + echo "reach anything that imports netbird as a library. Require the module" + echo "under a path you control instead, as done for github.com/netbirdio/go-nat." + exit 1 + fi + + removed=$(comm -23 /tmp/base-replaces /tmp/head-replaces) + if [ -n "$removed" ]; then + echo "This PR removes replace directives:" + echo "$removed" | sed 's/^/ /' + fi + echo "No new replace directives." diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index 67d65356c..24d81b50f 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -16,6 +16,8 @@ jobs: const allowedTags = [ 'management', 'client', + 'android', + 'ios', 'signal', 'proxy', 'relay', diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 76a5b36ff..51426a7ce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,6 +6,7 @@ on: - "v*" branches: - main + - "release-*" pull_request: env: @@ -185,6 +186,11 @@ jobs: run: bash shared/management/http/api/generate.sh - name: check git status run: git --no-pager diff --exit-code + - name: Generate RPM changelog from git tags + # nfpm embeds changelog.yml into the RPM; Red Hat software certification + # requires a changelog. Generated, not committed (see .gitignore). + # chglog is a go.mod tool directive, so go.sum pins it and its deps. + run: bash release_files/rpm-changelog.sh - name: Set up QEMU uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 #v4.1.0 - name: Set up Docker Buildx @@ -214,7 +220,7 @@ jobs: echo "GPG_RPM_KEY_FILE=/tmp/gpg-rpm-signing-key.asc" >> $GITHUB_ENV - name: Install goversioninfo - run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e + run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@b66839b # v1.7.0 - name: Generate windows syso amd64 run: goversioninfo -icon client/ui/build/windows/icon.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_amd64.syso - name: Generate windows syso arm64 @@ -254,15 +260,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 } @@ -278,10 +292,15 @@ jobs: image_refs=() tag_and_push() { - local src="$1" img_name tag dst + local src="$1" img_name tag dst variant="" img_name="${src%%:*}" + # Client variants share a repository, so keep their tag suffixes. + case "$src" in + *-rootless-ubi-amd64) variant="-rootless-ubi" ;; + *-rootless-amd64) variant="-rootless" ;; + esac for tag in $(resolve_tags); do - dst="${img_name}:${tag}" + dst="${img_name}:${tag}${variant}" echo "Tagging ${src} -> ${dst}" docker tag "$src" "$dst" docker push "$dst" @@ -426,7 +445,7 @@ jobs: tar -xf llvm-mingw-20250709-ucrt-ubuntu-22.04-x86_64.tar.xz echo "/tmp/llvm-mingw-20250709-ucrt-ubuntu-22.04-x86_64/bin" >> $GITHUB_PATH - name: Install goversioninfo - run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e + run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@b66839b # v1.7.0 - name: Install wails3 CLI # Version derived from go.mod so the binding generator always matches # the wails runtime the binary links against. @@ -475,6 +494,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 +833,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 +845,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 +875,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 +932,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 diff --git a/.github/workflows/sync-tag.yml b/.github/workflows/sync-tag.yml index d99f88b54..608f3c6d7 100644 --- a/.github/workflows/sync-tag.yml +++ b/.github/workflows/sync-tag.yml @@ -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, '-') @@ -49,3 +37,16 @@ jobs: repo: netbirdio/ios-client token: ${{ secrets.NC_GITHUB_TOKEN }} inputs: '{ "tag": "${{ github.ref_name }}" }' + + trigger_dashboard_bump: + runs-on: ubuntu-latest + if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-') + steps: + - name: Trigger dashboard wasm client bump + uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2 + with: + workflow: bump-netbird.yml + ref: main + repo: netbirdio/dashboard + token: ${{ secrets.NC_GITHUB_TOKEN }} + inputs: '{ "tag": "${{ github.ref_name }}" }' diff --git a/.github/workflows/test-infrastructure-files.yml b/.github/workflows/test-infrastructure-files.yml index 0a4f2e371..1313379ee 100644 --- a/.github/workflows/test-infrastructure-files.yml +++ b/.github/workflows/test-infrastructure-files.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - "release-*" pull_request: paths: - "infrastructure_files/**" @@ -249,78 +250,44 @@ jobs: docker compose exec management ls -l /var/lib/netbird/ | grep -i GeoLite2-City_[0-9]*.mmdb docker compose exec management ls -l /var/lib/netbird/ | grep -i geonames_[0-9]*.db - test-getting-started-script: + test-legacy-getting-started-scripts: runs-on: ubuntu-latest steps: - - name: Install jq - run: sudo apt-get install -y jq - - name: Checkout code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - name: run script with Zitadel PostgreSQL - run: NETBIRD_DOMAIN=use-ip bash -x infrastructure_files/getting-started-with-zitadel.sh - - - name: test Caddy file gen postgres - run: test -f Caddyfile - - - name: test docker-compose file gen postgres - run: test -f docker-compose.yml - - - name: test management.json file gen postgres - run: test -f management.json - - - name: test turnserver.conf file gen postgres + - name: Verify fresh-install session cookie key hardening run: | - set -x - test -f turnserver.conf - grep external-ip turnserver.conf + 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: test zitadel.env file gen postgres - run: test -f zitadel.env - - - name: test dashboard.env file gen postgres - run: test -f dashboard.env - - - name: test relay.env file gen postgres - run: test -f relay.env - - - name: test zdb.env file gen postgres - run: test -f zdb.env - - - name: Postgres run cleanup + - name: Verify Dex retirement notice run: | - docker compose down --volumes --rmi all - rm -rf docker-compose.yml Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json zdb.env + if infrastructure_files/getting-started-with-dex.sh >stdout.txt 2>stderr.txt; then + echo "Expected the retired Dex installer to fail" + exit 1 + fi + test ! -s stdout.txt + grep -Fq "Dex support is not deprecated." stderr.txt + grep -Fq "https://docs.netbird.io/selfhosted/selfhosted-quickstart" stderr.txt + grep -Fq "https://docs.netbird.io/selfhosted/identity-providers/local" stderr.txt + grep -Fq "removed in NetBird v0.80" stderr.txt - - name: run script with Zitadel CockroachDB - run: bash -x infrastructure_files/getting-started-with-zitadel.sh - env: - NETBIRD_DOMAIN: use-ip - ZITADEL_DATABASE: cockroach - - - name: test Caddy file gen CockroachDB - run: test -f Caddyfile - - - name: test docker-compose file gen CockroachDB - run: test -f docker-compose.yml - - - name: test management.json file gen CockroachDB - run: test -f management.json - - - name: test turnserver.conf file gen CockroachDB + - name: Verify Zitadel retirement notice run: | - set -x - test -f turnserver.conf - grep external-ip turnserver.conf - - - name: test zitadel.env file gen CockroachDB - run: test -f zitadel.env - - - name: test dashboard.env file gen CockroachDB - run: test -f dashboard.env - - - name: test relay.env file gen CockroachDB - run: test -f relay.env + if bash infrastructure_files/getting-started-with-zitadel.sh >stdout.txt 2>stderr.txt; then + echo "Expected the retired Zitadel installer to fail" + exit 1 + fi + test ! -s stdout.txt + grep -Fq "Zitadel support and existing Zitadel deployments are not deprecated." stderr.txt + grep -Fq "https://docs.netbird.io/selfhosted/selfhosted-quickstart" stderr.txt + grep -Fq "https://docs.netbird.io/selfhosted/identity-providers/zitadel" stderr.txt + grep -Fq "https://docs.netbird.io/selfhosted/selfhosted-guide" stderr.txt + grep -Fq "removed in NetBird v0.80" stderr.txt diff --git a/.github/workflows/ui-translations.yml b/.github/workflows/ui-translations.yml new file mode 100644 index 000000000..7d3b12f2d --- /dev/null +++ b/.github/workflows/ui-translations.yml @@ -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 diff --git a/.github/workflows/wasm-build-validation.yml b/.github/workflows/wasm-build-validation.yml index e8a12cdaf..5f21472e5 100644 --- a/.github/workflows/wasm-build-validation.yml +++ b/.github/workflows/wasm-build-validation.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - "release-*" pull_request: concurrency: diff --git a/.gitignore b/.gitignore index 305f3cb50..dd7eea76f 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,7 @@ vendor/ /netbird client/netbird-electron/ management/server/types/testdata/ + +# generated by chglog in the release workflow, embedded into the RPM +changelog.yml +.chglog.yml diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 0753b1012..19528f88e 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -234,10 +234,33 @@ nfpms: - netbird formats: - rpm + # The client verifies TLS to management and signal against the system trust + # store. Red Hat software certification (RPM Dependency Tracking) also + # rejects packages that declare no dependencies at all. + dependencies: + - ca-certificates + # Generated in CI by chglog from git tags; Red Hat certification requires an + # RPM changelog (RPM Version Handling subtest). + changelog: changelog.yml + # License, documentation and a config file so the RPM Provenance subtest sees + # %license, %doc and %config entries instead of a bare binary. + contents: + - src: LICENSE + dst: /usr/share/licenses/netbird/LICENSE + type: license + - src: README.md + dst: /usr/share/doc/netbird/README.md + type: doc + - src: release_files/netbird.sysconfig + dst: /etc/sysconfig/netbird + type: config|noreplace scripts: postinstall: "release_files/post_install.sh" preremove: "release_files/pre_remove.sh" rpm: + summary: NetBird client + group: Applications/Internet + packager: NetBird signature: key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}' dockers_v2: @@ -273,8 +296,8 @@ dockers_v2: - netbirdio/netbird - ghcr.io/netbirdio/netbird tags: - - "v{{ .Version }}-rootless" - - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + - "{{ .Version }}-rootless" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}rootless-latest{{ end }}" dockerfile: client/Dockerfile-rootless extra_files: - client/netbird-entrypoint.sh @@ -289,6 +312,43 @@ dockers_v2: "org.opencontainers.image.revision": "{{.FullCommit}}" "org.opencontainers.image.source": "{{.GitURL}}" "maintainer": "dev@netbird.io" + - id: netbird-rootless-ubi + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird + images: + - netbirdio/netbird + - ghcr.io/netbirdio/netbird + tags: + - "{{ .Version }}-rootless-ubi" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}rootless-ubi-latest{{ end }}" + dockerfile: client/Dockerfile-rootless.ubi + extra_files: + - client/netbird-entrypoint.sh + platforms: + - linux/amd64 + - linux/arm64 + build_args: + VERSION: "{{ .Version }}" + RELEASE: "{{ .Timestamp }}" + hooks: + pre: + - cmd: 'sh client/collect-licenses.sh "{{ .ContextDir }}/licenses" amd64 arm64' + env: + - GOOS=linux + - CGO_ENABLED=0 + labels: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" - id: relay disable: "{{ .Env.SKIP_DOCKER_PUSH }}" ids: @@ -468,6 +528,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 diff --git a/.goreleaser_ui.yaml b/.goreleaser_ui.yaml index 197fcd440..24903188f 100644 --- a/.goreleaser_ui.yaml +++ b/.goreleaser_ui.yaml @@ -24,6 +24,8 @@ builds: 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 - id: netbird-ui-windows-amd64 dir: client/ui @@ -39,6 +41,8 @@ builds: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser - -H windowsgui mod_timestamp: "{{ .CommitTimestamp }}" + tags: + - production - id: netbird-ui-windows-arm64 dir: client/ui @@ -55,6 +59,8 @@ builds: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser - -H windowsgui mod_timestamp: "{{ .CommitTimestamp }}" + tags: + - production archives: - id: linux-arch @@ -86,8 +92,16 @@ nfpms: dst: /usr/share/applications/org.wails.netbird.desktop - src: client/ui/build/appicon.png dst: /usr/share/pixmaps/netbird.png + # Names the polkit action for the elevation prompt the app raises when an + # unprivileged user changes a privileged setting; without it the dialog + # shows a raw command line. + - src: client/ui/build/linux/polkit/io.netbird.settings.policy + dst: /usr/share/polkit-1/actions/io.netbird.settings.policy dependencies: - - netbird + - netbird (>= 0.75.0) + - libgtk-4-1 (>= 4.14) + - libwebkitgtk-6.0-4 + - xdg-utils - maintainer: Netbird description: Netbird client UI. @@ -107,8 +121,16 @@ nfpms: dst: /usr/share/applications/org.wails.netbird.desktop - src: client/ui/build/appicon.png dst: /usr/share/pixmaps/netbird.png + # Names the polkit action for the elevation prompt the app raises when an + # unprivileged user changes a privileged setting; without it the dialog + # shows a raw command line. + - src: client/ui/build/linux/polkit/io.netbird.settings.policy + dst: /usr/share/polkit-1/actions/io.netbird.settings.policy 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: @@ -132,3 +154,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 diff --git a/.goreleaser_ui_darwin.yaml b/.goreleaser_ui_darwin.yaml index 96e15371a..8ca0e8da6 100644 --- a/.goreleaser_ui_darwin.yaml +++ b/.goreleaser_ui_darwin.yaml @@ -29,6 +29,8 @@ builds: 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 universal_binaries: - id: netbird-ui-darwin @@ -41,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 diff --git a/.goreleaser_ui_gtk3.yaml b/.goreleaser_ui_gtk3.yaml new file mode 100644 index 000000000..a9b2ca650 --- /dev/null +++ b/.goreleaser_ui_gtk3.yaml @@ -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 + 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 + 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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..3838a7913 --- /dev/null +++ b/AGENTS.md @@ -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 .githooks: pre-push runs make lint, commit-msg refuses attribution trailers + +# 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 --watch # all checks, live +gh run view --log-failed # only the failing steps +gh pr view --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: +- Slack: +- Docs: +- Security: — never in public +- Contribution process: [CONTRIBUTING.md](CONTRIBUTING.md) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..72681748e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,4 @@ +The agent guidelines live in [AGENTS.md](AGENTS.md). It is imported here so +every session loads it in full rather than following a pointer. + +@AGENTS.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 261083783..aef749cfa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to NetBird -Thanks for your interest in contributing to NetBird. +Thanks for your interest in contributing to NetBird. There are many ways that you can contribute: - Reporting issues @@ -10,12 +10,99 @@ There are many ways that you can contribute: If you haven't already, join our slack workspace [here](https://docs.netbird.io/slack-url), we would love to discuss topics that need community contribution and enhancements to existing features. +## Ticket first, PR second + +**Open a ticket and wait for feedback before you open a pull request.** Every PR +that changes behavior must link to an issue the NetBird team has agreed on. A PR +that arrives without one may be closed and redirected to a discussion, no matter +how good the code is. + +Issues in this repository are maintainer-curated work items, so the flow starts +in [Discussions](https://github.com/netbirdio/netbird/discussions): + +1. **Open a discussion.** Use + [Issue Triage](https://github.com/netbirdio/netbird/discussions/new?category=issue-triage) + for a bug, regression, or unexpected behavior, and + [Ideas & Feature Requests](https://github.com/netbirdio/netbird/discussions/new?category=ideas-feature-requests) + for a feature, enhancement, or integration idea. Setup and usage questions + belong in + [Q&A / Support](https://github.com/netbirdio/netbird/discussions/new?category=q-a-support). + Never report a security vulnerability in public — follow the + [security policy](https://github.com/netbirdio/netbird/security/policy) + instead. +2. **Wait for feedback.** DevRel validates and reproduces the report, and a + maintainer confirms the direction. We may ask for more detail or propose a + different approach. Validated discussions become issues. +3. **Then write the code**, following the approach agreed in the issue, and open + the PR linking that issue. + +Trivial fixes — a typo, a broken link, a documentation correction, or a one-line +fix that already has an issue — can go straight to a PR. Everything else starts +with a ticket. When in doubt, ask in the discussion or on +[Slack](https://docs.netbird.io/slack-url); an hour of conversation up front +regularly saves a week of rework. + +### High-risk areas + +These always need the design discussed and agreed in the issue **before** you +write code: + +- **Public API** — REST / management API, OpenAPI schema, dashboard-facing contracts +- **gRPC protocols** — management, signal, relay, and client daemon protos +- **Functionality behavior** — anything existing deployments would experience differently after an upgrade +- **Peer connectivity** — ICE and NAT traversal, relay selection, WireGuard® and Rosenpass key handling +- **Client system integration** — routing, firewall, DNS, and interface management +- **Authentication and authorization** — IdP integration, tokens, permissions, cryptography +- **CLI / service flags**, configuration file format, and daemon IPC +- **Store and database schema** — models and migrations +- **New features** + +These surfaces are NetBird's contract with operators, self-hosters, and +downstream integrators, and changes to them have compatibility, security, and +release-planning implications. Agreeing on the direction early lets the PR +review focus on implementation rather than design. + +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) - - [Discuss changes with the NetBird team first](#discuss-changes-with-the-netbird-team-first) - [Directory structure](#directory-structure) - [Development setup](#development-setup) - [Requirements](#requirements) @@ -24,6 +111,8 @@ If you haven't already, join our slack workspace [here](https://docs.netbird.io/ - [Build and start](#build-and-start) - [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) @@ -34,42 +123,66 @@ Conduct which can be found in the file [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to community@netbird.io. -## Discuss changes with the NetBird team first - -Changes to the **public API**, **gRPC protocols**, **functionality behavior**, **CLI / service flags**, or **new features** should be discussed with the NetBird team before you start the work. These surfaces are part of NetBird's contract with operators, self-hosters, and downstream integrators, and changes to them have compatibility, security, and release-planning implications that benefit from an early conversation. - -Open an issue or reach out on [Slack](https://docs.netbird.io/slack-url) to talk through what you have in mind. We'll help shape the change, flag any constraints we know about, and confirm the direction so the PR review can focus on implementation rather than design. - -Typical bug fixes, internal refactors, documentation updates, and tests do not need pre-discussion — open the PR directly. - ## Directory structure -The NetBird project monorepo is organized to maintain most of its individual dependencies code within their directories, except for a few auxiliary or shared packages. +The NetBird project monorepo keeps most of each component's code within its own +directory, except for a few auxiliary or shared packages. Protocol definitions +and the client-side service clients live under [/shared](/shared), because both +the agent and the services import them. -The most important directories are: +**Agent** -- [/.github](/.github) - Github actions workflow files and issue templates - [/client](/client) - NetBird agent code -- [/client/cmd](/client/cmd) - NetBird agent cli code +- [/client/cmd](/client/cmd) - NetBird agent CLI code - [/client/internal](/client/internal) - NetBird agent business logic code -- [/client/proto](/client/proto) - NetBird agent daemon GRPC proto files - [/client/server](/client/server) - NetBird agent daemon code for background execution -- [/client/ui](/client/ui) - NetBird agent UI code -- [/encryption](/encryption) - Contain main encryption code for agent communication -- [/iface](/iface) - Wireguard® interface code -- [/infrastructure_files](/infrastructure_files) - Getting started files containing docker and template scripts +- [/client/proto](/client/proto) - NetBird agent daemon gRPC proto files +- [/client/iface](/client/iface) - WireGuard® interface code +- [/client/firewall](/client/firewall) - Platform firewall backends (nftables, iptables, pf, WFP, userspace) +- [/client/ssh](/client/ssh) - Built-in SSH server and client +- [/client/ui](/client/ui) - NetBird agent UI code (Wails v3 + React) +- [/client/android](/client/android), [/client/ios](/client/ios) - Mobile platform bindings +- [/client/wasm](/client/wasm) - WebAssembly build of the agent +- [/client/mdm](/client/mdm) - MDM-delivered policy handling +- [/client/system](/client/system) - Host and system information collection + +**Control plane services** + - [/management](/management) - Management service code -- [/management/client](/management/client) - Management service client code which is imported by the agent code -- [/management/proto](/management/proto) - Management service GRPC proto files - [/management/server](/management/server) - Management service server code - [/management/server/http](/management/server/http) - Management service REST API code +- [/management/server/store](/management/server/store) - Persistence layer and migrations - [/management/server/idp](/management/server/idp) - Management service IDP management code -- [/release_files](/release_files) - Files that goes into release packages +- [/management/server/peer](/management/server/peer), [/management/server/groups](/management/server/groups), [/management/server/networks](/management/server/networks), [/management/server/posture](/management/server/posture), [/management/server/permissions](/management/server/permissions) - Core domain packages - [/signal](/signal) - Signal service code -- [/signal/client](/signal/client) - Signal service client code which is imported by the agent code - [/signal/peer](/signal/peer) - Signal service peer message logic -- [/signal/proto](/signal/proto) - Signal service GRPC proto files - [/signal/server](/signal/server) - Signal service server code +- [/relay](/relay) - Relay service code +- [/relay/protocol](/relay/protocol) - Relay wire protocol +- [/proxy](/proxy) - Identity-aware proxy used by Agent Network (LLM routing, ACME, access logs) +- [/agent-network](/agent-network) - Agent Network overview and documentation +- [/upload-server](/upload-server) - Debug bundle upload service + +**Shared code** + +- [/shared/management/proto](/shared/management/proto) - Management service gRPC proto files +- [/shared/management/client](/shared/management/client) - Management service client code which is imported by the agent code +- [/shared/management/http/api](/shared/management/http/api) - OpenAPI specification and generated REST API types +- [/shared/signal/proto](/shared/signal/proto) - Signal service gRPC proto files +- [/shared/signal/client](/shared/signal/client) - Signal service client code which is imported by the agent code +- [/shared/relay](/shared/relay) - Relay client and shared relay types +- [/shared/auth](/shared/auth), [/shared/sshauth](/shared/sshauth) - Shared authentication primitives +- [/encryption](/encryption) - Contain main encryption code for agent communication +- [/dns](/dns), [/route](/route), [/stun](/stun), [/sharedsock](/sharedsock), [/util](/util) - Shared networking and utility primitives +- [/flow](/flow) - Flow event protocol shared by the agent and Management + +**Build, test, and packaging** + +- [/.github](/.github) - Github actions workflow files, issue templates, and the pull request template +- [/e2e](/e2e) - End-to-end test suites and harness +- [/infrastructure_files](/infrastructure_files) - Getting started files containing docker and template scripts +- [/release_files](/release_files) - Files that goes into release packages +- [/tools](/tools) - Development and maintenance tooling ## Development setup @@ -79,7 +192,7 @@ dependencies are installed. Here is a short guide on how that can be done. ### Requirements -#### Go 1.25 +#### Go 1.26 Follow the installation guide from https://go.dev/ @@ -87,7 +200,7 @@ Follow the installation guide from https://go.dev/ The desktop UI client (`client/ui`) is built with [Wails v3](https://v3.wails.io/) and a React frontend rendered in a WebView. To build it you need: -- Go ≥ 1.25 +- Go ≥ 1.26 - Node ≥ 20 and **pnpm** (`corepack enable && corepack prepare pnpm@latest --activate`) - The `wails3` CLI: `go install github.com/wailsapp/wails/v3/cmd/wails3@latest` - The `task` runner: `go install github.com/go-task/task/v3/cmd/task@latest` @@ -234,12 +347,22 @@ cd client/ui task dev ``` -Pass daemon flags after `--`: +Pass daemon flags after `--`, pointing the UI at the socket the daemon serves: ``` -task dev -- --daemon-addr=tcp://127.0.0.1:41731 +task dev -- --daemon-addr=unix:///var/run/netbird.sock # Linux, macOS +task dev -- --daemon-addr=npipe://netbird # Windows ``` +On Windows the daemon serves a named pipe (`npipe://netbird`). Which path that +ends up being depends on what the daemon may create: as a service or elevated it +serves `\\.\pipe\ProtectedPrefix\Administrators\netbird`, which no unprivileged +process can take from it, and otherwise it falls back to `\\.\pipe\netbird`. +Clients try both and check who owns the pipe before using the plain one. Avoid +`tcp://127.0.0.1:41731`: loopback TCP carries no caller identity, so the daemon +refuses the operations that require an administrator and you will not exercise +those paths. + Production build (frontend assets embedded into the binary, output in `client/ui/bin/`): ``` @@ -324,23 +447,183 @@ The installer `netbird-installer.exe` will be created in root directory. ### Test suite -The tests can be started via: +The host-safe unit tests run as a normal user and leave host networking +untouched: ``` -cd netbird -go test -exec sudo ./... +make test-unit ``` + +Tests that need root and mutate host networking (firewall, routing, interface +management) carry the `privileged` build tag and run inside a +`--privileged --cap-add=NET_ADMIN` Docker container: + +``` +make test-privileged +``` + +Narrow a privileged run with environment variables: + +``` +PRIV_RUN=TestNftablesManager PRIV_PKGS=./client/firewall/nftables/... make test-privileged +``` + +Single packages can be run directly, adding `-race` when the change touches +shared state: + +``` +go test -race ./client/internal/dns/... +``` + > On Windows use a powershell with administrator privileges ## Checklist before submitting a PR -As a critical network service and open-source project, we must enforce a few things before submitting the pull-requests: + +As a critical network service and open source project, we must enforce a few +things before submitting a pull request. The +[pull request template](/.github/pull_request_template.md) mirrors this list — +fill it in rather than deleting it. + +### Link the issue + +The PR description must link the agreed issue (or the validated discussion it +came from). See [Ticket first, PR second](#ticket-first-pr-second). + +### Run it locally + +**If you can't run it, you can't submit it.** Build the affected components and +exercise the change on a real setup — see [Build and start](#build-and-start). +"CI will tell me" is not acceptable for a VPN agent that runs as root on other +people's machines. + +### Green CI, and answer the bots + +We do not start reviewing while CI is red. Get the pipeline green first — a +failing build, lint, or test means the PR is not ready for review. + +Alongside the test workflows, your PR is reviewed by CodeRabbit and scanned by +SonarCloud, Snyk, and Codecov. Read what they report and either fix it or reply +with why it does not apply; please do not resolve the threads without a +response. They are not always right — this codebase has privileged, +platform-specific, and concurrency-heavy paths that static analysis reads poorly +— so push back when a finding is wrong rather than changing correct code to +silence it. Security and dependency findings are the exception: treat those as +real until shown otherwise. Do not edit workflows, thresholds, or scanner +configuration to make a check pass. + +### One PR, one purpose + +Bug fix, refactor, feature: separate PRs. Mixed PRs are slow to review, hard to +revert, and may be closed with a request to split them. + +### Keep it small + +Size is the strongest predictor of how long a PR waits for review. Aim for under +roughly 400 changed lines across under 20 files. Past about 1000 lines or 50 +files, expect to be asked to split the change — and large PRs from outside the +core team may be blocked until the scope has been agreed in a ticket. This is +not only about reviewer time: NetBird's agent runs as root on other people's +machines, and a sprawling diff cannot be reviewed with the care that deserves. + +Measure by hand-written code, excluding generated output, `go.sum`, and +fixtures. If a change genuinely cannot be small — a protocol migration, a +cross-component rename — agree the split in the issue before you start, and land +it as a series of PRs that each build and make sense on their own. + +### Avoid force-pushing during review + +Once a PR is open, push new commits instead of rewriting history. A force-push +detaches existing review comments from their lines, throws away the +"changes since your last review" diff, and loses the CI history that showed +which commit broke what. Since we squash on merge, there is nothing to gain from +a tidy branch history. + +Force-pushing is sometimes unavoidable — rebasing to clear a real conflict, or +removing a secret or large binary committed by mistake. When that happens, leave +a comment on the PR so reviewers know their anchors moved. + +### Quality checks + +Run these from the repository root before pushing: + +```shell +go fmt ./... +make lint # golangci-lint on files changed against origin/main +make lint-all # full-repository lint, matches CI +make test-unit # host-safe unit tests +``` + +`make setup-hooks` wires `make lint` into a pre-push hook so the fast lint runs +automatically. If your change touches privileged paths (firewall, routing, +interface management), also run `make test-privileged`, which executes the +`privileged`-tagged suite inside a Docker container with `NET_ADMIN`. + +### Code standards + - Keep functions as simple as possible, with a single purpose - Use private functions and constants where possible - Comment on any new public functions - Add unit tests for any new public function +- Comment the **why**, not the **what** — explain non-obvious decisions, invariants, and constraints, not the line below +- Keep comments within 90 characters per line and roughly 250 characters per comment; when a block needs more explanation than that, extract a named function instead of writing a longer comment (see [AGENTS.md](AGENTS.md#length-budget)) + +### PR title and commits + +PR titles must start with a bracketed tag, enforced by +[pr-title-check.yml](/.github/workflows/pr-title-check.yml): + +```text +[client] Authorize daemon IPC callers by their local identity +[management,client] Add MDM policy support +``` + +Use a comma-separated list inside a single pair of brackets when a change spans +components. The `allowedTags` array in +[pr-title-check.yml](/.github/workflows/pr-title-check.yml) is the source of +truth — at the time of writing it accepts `management`, `client`, `signal`, +`proxy`, `relay`, `misc`, `infrastructure`, `self-hosted`, and `doc`. + +Commit subjects follow the same convention — keep them short and put the +reasoning in the body, why before what, with no bullet list of files changed. + +Keep the PR description itself under 1000 words on top of the template text. +Reviewers read the diff; the description explains what the diff cannot. > When pushing fixes to the PR comments, please push as separate commits; we will squash the PR before merging, so there is no need to squash it before pushing it, and we are more than okay with 10-100 commits in a single PR. This helps review the fixes to the requested changes. +### Documentation + +User-facing changes need a matching PR in +[netbirdio/docs](https://github.com/netbirdio/docs); link it in the PR +description, or state why documentation is not needed. + +## When we close a PR + +We would rather redirect early than let a PR sit. We may close one if: + +- It changes behavior with no linked issue, or the approach was never agreed with a maintainer +- The change was clearly never run or tested locally +- CI has been red without a response +- It mixes unrelated purposes, or the purpose is not clear +- It is far too large to review and the scope was never agreed in a ticket +- The author cannot answer questions about their own change — including PRs that read as unreviewed model output, where review turns into a relay between the maintainer and an LLM. Tooling is fine; unreviewed output is not, you are responsible for the code you sign your name to +- There has been no activity for 14 days after we requested changes + +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: diff --git a/Makefile b/Makefile index 0a4fad2f2..26c5b932e 100644 --- a/Makefile +++ b/Makefile @@ -23,8 +23,8 @@ lint-install: $(GOLANGCI_LINT) # Setup git hooks for all developers setup-hooks: @git config core.hooksPath .githooks - @chmod +x .githooks/pre-push - @echo "✅ Git hooks configured! Pre-push will now run 'make lint'" + @chmod +x .githooks/pre-push .githooks/commit-msg + @echo "✅ Git hooks configured! Pre-push runs 'make lint'; commit-msg refuses attribution trailers" # Host-safe unit tests: excludes the privileged-tagged tests (root / system-mutating). # Runs as a normal user with no sudo and leaves host networking untouched. diff --git a/README.md b/README.md index 40c6b9ed5..336332043 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Start using NetBird at netbird.io
- See Documentation + See Documentation
Join our Slack channel or our Community forum
@@ -130,7 +130,7 @@ In November 2022, NetBird joined the [StartUpSecure program](https://www.forschu ![CISPA_Logo_BLACK_EN_RZ_RGB (1)](https://user-images.githubusercontent.com/700848/203091324-c6d311a0-22b5-4b05-a288-91cbc6cdcc46.png) ### Acknowledgements -We build on open-source technologies like [WireGuard®](https://www.wireguard.com/), [Pion ICE](https://github.com/pion/ice), and [Rosenpass](https://rosenpass.eu). We greatly appreciate the work these projects are doing, and we'd love it if you could support them too (e.g., by starring or contributing). +We build on open source technologies like [WireGuard®](https://www.wireguard.com/), [Pion ICE](https://github.com/pion/ice), and [Rosenpass](https://rosenpass.eu). We greatly appreciate the work these projects are doing, and we'd love it if you could support them too (e.g., by starring or contributing). ### Legal This repository is licensed under the BSD-3-Clause license, which applies to all parts of the repository except for the directories management/, signal/ and relay/. diff --git a/SECURITY.md b/SECURITY.md index 745c66e61..cbcc975ba 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,12 +1,70 @@ # Security Policy -NetBird's goal is to provide a secure network. If you find a vulnerability or bug, please report it by opening an issue [here](https://github.com/netbirdio/netbird/issues/new?assignees=&labels=&template=bug-issue-report.md&title=) or by contacting us by email. - -There has yet to be an official bug bounty program for the NetBird project. - -## Supported Versions -- We currently support only the latest version +NetBird's goal is to provide a secure network. The client runs as a privileged service on every machine it is installed on, +so we take reports about it seriously and we publish what we fix. ## Reporting a Vulnerability -Please report security issues to `security@netbird.io` +**Please do not open a public issue for a security vulnerability.** Public issues are visible to everyone, including before +a fix is available. + +Report security issues one of these two ways: + +- **GitHub private vulnerability reporting** — [open a private report](https://github.com/netbirdio/netbird/security/advisories/new) + on this repository. This is the preferred route: it keeps the discussion, the draft advisory, and the credit in one place. +- **Email** — `security@netbird.io`. + +If the finding affects NetBird Cloud or our hosted infrastructure rather than the open source code, email us rather than +filing a repository report. + +### What to include + +A report is easier to act on when it contains: + +- The affected component (client, management, signal, relay, dashboard) and the version or commit you tested +- The platform and configuration, where relevant — operating system, self-hosted or NetBird Cloud, container or host install +- What an attacker needs before they can exploit it: network position, an account, local access, a specific privilege level +- Steps to reproduce, and a proof of concept if you have one +- The impact you believe it has + +Partial reports are still welcome. If you are unsure whether something is a security issue, send it to `security@netbird.io` +and let us make that call. + +## What to expect from us + +- **We acknowledge your report** and tell you whether we can reproduce it. +- **We work with you on severity and scope.** If we assess it differently than you do, we will explain why rather than + silently downgrade it. +- **We fix and release**, then publish a [GitHub Security Advisory](https://github.com/netbirdio/netbird/security/advisories) + naming the affected version range and the patched version. +- **We credit reporters who want to be credited.** Tell us the name or handle you would like used, or that you would rather + stay anonymous. +- **We keep you in the loop** until the advisory is published. + +We ask that you give us a reasonable opportunity to ship a fix before disclosing the issue publicly, and that you avoid +accessing, modifying, or exfiltrating data belonging to other people while testing. Testing against your own installation +or your own account is always fine. + +## Supported Versions + +We support the latest release. Security fixes ship in the next version rather than as backports to older releases, so +upgrading to the current release is how you get them. + +Release notifications are available by watching [releases](https://github.com/netbirdio/netbird/releases). + +## Published advisories + +Every vulnerability we fix is published as a GitHub Security Advisory on the +[advisories page](https://github.com/netbirdio/netbird/security/advisories), including the affected version range, the +patched version, and the reporter's credit. Advisories for the Go module are also distributed through the Go vulnerability +database, so `govulncheck` will report them against your dependencies. + +## Bug bounty + +There is no official bug bounty program for the NetBird project. We credit reporters in advisories, and we are grateful for +the work, but we cannot currently offer payment for reports. + +## Non-security bugs + +For bugs that are not security issues, please use the +[issue tracker](https://github.com/netbirdio/netbird/discussions/new/choose). diff --git a/agent-network/README.md b/agent-network/README.md index 1997ea299..029ada299 100644 --- a/agent-network/README.md +++ b/agent-network/README.md @@ -40,6 +40,35 @@ You can then use this private endpoint to configure your AI agents, whether that Full step-by-step setup: **https://docs.netbird.io/agent-network/quickstart** +## Client settings that don't follow the endpoint + +Most of an agent's traffic follows the base URL you hand it, but a few +client-side checks call their vendor directly and never reach the proxy. On a +network that blocks direct egress they fail even though inference works, so +they are worth setting once when you roll the endpoint out. + +For Claude Code: + +- **Fast mode** checks availability against `api.anthropic.com` rather than the + configured base URL. Set `CLAUDE_CODE_SKIP_FAST_MODE_ORG_CHECK=1` when the + agent authenticates with `ANTHROPIC_AUTH_TOKEN` alone (the usual shape when + the proxy injects the real provider key) or when a TLS-inspecting proxy + answers the check itself. Set + `CLAUDE_CODE_SKIP_FAST_MODE_NETWORK_ERRORS=1` when the network refuses the + connection outright. Fast mode is an Anthropic-API feature, so it is + unavailable on a Bedrock- or Vertex-backed endpoint whatever you set. +- **Model discovery** is off by default. Set + `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1` for the picker to list the + models your policies authorise; the proxy filters the response to that set. + The client gives discovery a three-second budget and treats any redirect as + a failure, so the endpoint must serve `/v1/models` directly. +- **The WebFetch domain safety check** also calls `api.anthropic.com` directly + and is unaffected by the variables above. + +Allowing direct egress to `api.anthropic.com` covers the network cases but not +the credential one, where the check reaches Anthropic and is rejected because +the agent presents a proxy-issued key. + ## Architecture Agent Network is built on two existing NetBird capabilities: @@ -67,6 +96,42 @@ components: — the management-side control plane: providers, policies, guardrails, limits, routing, and usage/access logs. +## Access roles + +Agent Network permissions build on the account permission matrix +([`management/server/permissions/`](../management/server/permissions)). The +`agent_network` area is split into dotted submodules (`agent_network.providers`, +`.policies`, `.guardrails`, `.budgets`, `.usage`, `.logs`, `.settings`); a role may +grant a single submodule or the parent, which cascades to all of them. + +Two roles delegate Agent Network access without account-admin rights: + +- **`agent_network_admin`** — full control over the whole `agent_network` area plus + read-only users, groups, peers, and account info (needed to build policies). + Nothing else in the account. +- **`usage_viewer`** — the regular User baseline plus read on + `agent_network.usage` (the aggregated usage and cost overview) and read-only + access to the resources the usage filters resolve against: users, groups, + peers, and the provider list (connection config redacted — no upstream URLs + or operator-supplied header values). No policies, and no account-wide + request-level access logs; like any caller, it still reads its own requests + through the self-scoped endpoints below. + +Every authenticated user, regardless of role, can read the caller-scoped +self-service endpoint `GET /api/agent-network/agent-config` (the endpoint, providers, +and models the caller's own policies allow — what a local AI tool needs and nothing +more). The regular usage and access-log endpoints self-scope instead of denying: +a caller without the account-wide grant gets their own rows back, so "my usage" +and "my requests" are the same endpoints the admin dashboard uses. The provider +list self-scopes the same way — a caller without the providers grant gets the +providers their own policies authorize, reduced to the display surface, with +each provider's model list cut to what the caller's policy guardrails and the +provider's declared models effectively permit (the same computation the setup +answer and the proxy use). This feeds the dashboard's provider and model +filters. Role +definitions live in +[`management/server/permissions/roles/`](../management/server/permissions/roles). + ## Documentation Full documentation, architecture, and quickstart: diff --git a/client/Dockerfile-rootless.ubi b/client/Dockerfile-rootless.ubi new file mode 100644 index 000000000..4701728c1 --- /dev/null +++ b/client/Dockerfile-rootless.ubi @@ -0,0 +1,45 @@ +FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:7fbeae18dc9476399f565e68255f602a3374ea8614ba3d14843565131a13ff93 + +ARG TARGETPLATFORM +ARG NETBIRD_BINARY=$TARGETPLATFORM/netbird +ARG VERSION=dev +ARG RELEASE=1 + +LABEL name="netbird-rootless" \ + maintainer="NetBird " \ + vendor="NetBird GmbH" \ + version="${VERSION}" \ + release="${RELEASE}" \ + summary="NetBird Rootless Client" \ + description="NetBird connects devices through an encrypted overlay using userspace networking without a TUN device or network administration capabilities." + +RUN microdnf install -y bash ca-certificates && microdnf clean all + +COPY --chmod=0555 client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh +COPY --chmod=0555 ${NETBIRD_BINARY} /usr/local/bin/netbird +COPY licenses/ /licenses/ +# Only application storage is group-writable for arbitrary non-root UIDs. +# Runtime-created credentials keep the client's restrictive file modes. +RUN mkdir -p /var/lib/netbird && \ + chown 1000:0 /var/lib/netbird && \ + chmod 0770 /var/lib/netbird && \ + chmod -R a+rX /licenses + +WORKDIR /var/lib/netbird +USER 1000:0 + +ENV \ + HOME="/var/lib/netbird" \ + NETBIRD_BIN="/usr/local/bin/netbird" \ + NB_USE_NETSTACK_MODE="true" \ + NB_ENABLE_NETSTACK_LOCAL_FORWARDING="true" \ + NB_CONFIG="/var/lib/netbird/config.json" \ + NB_STATE_DIR="/var/lib/netbird" \ + NB_DAEMON_ADDR="unix:///var/lib/netbird/netbird.sock" \ + NB_LOG_FILE="console,/var/lib/netbird/client.log" \ + NB_DISABLE_DNS="true" \ + NB_ENABLE_CAPTURE="false" \ + NB_ENTRYPOINT_SERVICE_TIMEOUT="30" + +STOPSIGNAL SIGTERM +ENTRYPOINT ["/usr/local/bin/netbird-entrypoint.sh"] diff --git a/client/android/client.go b/client/android/client.go index 99ccdf393..e47a1c13d 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -7,13 +7,16 @@ import ( "fmt" "os" "slices" + "strings" "sync" + "sync/atomic" "time" "golang.org/x/exp/maps" 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" @@ -24,6 +27,7 @@ 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/netevents" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/formatter" "github.com/netbirdio/netbird/route" @@ -31,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 { @@ -56,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()) } @@ -70,18 +82,51 @@ type Client struct { deviceName string uiVersion string networkChangeListener listener.NetworkChangeListener + // netMgr outlives engine restarts: it mirrors the OS connectivity, not + // the engine lifecycle. Run and RunWithoutLogin inject its state and + // sweeper into each new ConnectClient. + netMgr *netevents.Manager stateMu sync.RWMutex connectClient *internal.ConnectClient config *profilemanager.Config cacheDir string + + // mdmSource holds the per-Client MDM policy source and its change + // detector as one unit. Set by SetMDMPolicyFetcher (called from the + // Kotlin side). Each Run passes the loader to the resolved Config so + // applyMDMPolicy picks up the active overlay. Nil means "MDM + // enforcement off for this Client". + mdmSource atomic.Pointer[mdmSource] + + // Identifies the running profile for the SSO login hint; see profile_state.go. + cfgPath string + + stateChangeMu sync.Mutex + stateChangeSubID string + eventSub *peer.EventSubscription + // Closed to stop the watch goroutines from delivering buffered items to a + // listener that has been removed or replaced. See stopStateChangeWatchLocked. + stateChangeDone chan struct{} + + // Latched "the server wants an interactive login": survives the engine + // restarts that replace the run loop's context state. See Client.Status. + // Guarded by loginRequiredMu together with loginCleared, which counts + // clears so a stale observation cannot re-latch over one. + loginRequiredMu sync.Mutex + loginRequired bool + loginCleared uint64 + + extendMu sync.Mutex + extendCancel context.CancelFunc } -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 } @@ -91,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() @@ -102,14 +157,17 @@ func NewClient(androidSDKVersion int, deviceName string, uiVersion string, tunAd execWorkaround(androidSDKVersion) net.SetAndroidProtectSocketFn(tunAdapter.ProtectSocket) + system.SetIFaceDiscover(iFaceDiscover) + recorder := peer.NewRecorder("") return &Client{ deviceName: deviceName, uiVersion: uiVersion, tunAdapter: tunAdapter, iFaceDiscover: iFaceDiscover, - recorder: peer.NewRecorder(""), + recorder: recorder, ctxCancelLock: &sync.Mutex{}, networkChangeListener: networkChangeListener, + netMgr: netevents.NewManager(recorder), } } @@ -129,6 +187,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid if err != nil { return err } + c.applyMDMOverlay(cfg) c.recorder.UpdateManagementAddress(cfg.ManagementURL.String()) c.recorder.UpdateRosenpass(cfg.RosenpassEnabled, cfg.RosenpassPermissive) @@ -143,16 +202,23 @@ 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.WithNetEvents(c.netMgr)) + 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() + // still reads the previous run's context state, which holds the NeedsLogin + // that prompted this login, and would re-latch what was just cleared. + c.clearLoginRequired() return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir) } @@ -173,6 +239,7 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR if err != nil { return err } + c.applyMDMOverlay(cfg) c.recorder.UpdateManagementAddress(cfg.ManagementURL.String()) c.recorder.UpdateRosenpass(cfg.RosenpassEnabled, cfg.RosenpassPermissive) @@ -186,8 +253,9 @@ 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.WithNetEvents(c.netMgr)) + c.setState(cfg, cacheDir, cfgFile, connectClient) return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir) } @@ -216,9 +284,49 @@ 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. +// Losing the last network also sweeps the registered connections: nothing can +// redial while offline, so the stale sockets would otherwise stay silently +// "connected" until their own timeouts and the client would keep reporting +// Connected with no network at all. +func (c *Client) SetNetworkAvailable(available bool) { + c.netMgr.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.netMgr.NotifyNetworkChange() +} + // 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 @@ -230,6 +338,7 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin if err != nil { return "", fmt.Errorf("load config: %w", err) } + c.applyMDMOverlay(cfg) cacheDir = platformFiles.CacheDir() } @@ -237,6 +346,7 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin InternalConfig: cfg, StatusRecorder: c.recorder, TempDir: cacheDir, + StatePath: platformFiles.StateFilePath(), } if cc != nil { @@ -247,6 +357,9 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin deps.SyncResponse = resp if e := cc.Engine(); e != nil { + deps.RefreshStatus = func() { + e.RunHealthProbes(context.Background(), true) + } if cm := e.GetClientMetrics(); cm != nil { deps.ClientMetrics = cm } @@ -257,6 +370,7 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin deps, debug.BundleConfig{ Anonymize: anonymize, + AnonymizeLevel: nbAnonymize.ParseLevel(anonymizeLevel), IncludeSystemInfo: true, }, ) @@ -274,7 +388,7 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path) + key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path, false) if err != nil { return "", fmt.Errorf("upload debug bundle: %w", err) } @@ -296,6 +410,13 @@ func (c *Client) SetInfoLogLevel() { // PeersList return with the list of the PeerInfos func (c *Client) PeersList() *PeerInfoArray { + // The recorder only caches transfer counters and handshake times; nothing + // refreshes them on its own, so without this they read as zero. The desktop + // daemon does the same before serving a full peer status. + if err := c.recorder.RefreshWireGuardStats(); err != nil { + log.Debugf("failed to refresh WireGuard stats: %v", err) + } + fullStatus := c.recorder.GetFullStatus() peerInfos := make([]PeerInfo, len(fullStatus.Peers)) @@ -306,6 +427,20 @@ func (c *Client) PeersList() *PeerInfoArray { FQDN: p.FQDN, ConnStatus: int(p.ConnStatus), Routes: PeerRoutes{routes: maps.Keys(p.GetRoutes())}, + + PubKey: p.PubKey, + Latency: formatDuration(p.Latency), + LatencyMs: p.Latency.Milliseconds(), + BytesRx: p.BytesRx, + BytesTx: p.BytesTx, + ConnStatusUpdate: formatTime(p.ConnStatusUpdate), + Relayed: p.Relayed, + RosenpassEnabled: p.RosenpassEnabled, + LastWireguardHandshake: formatTime(p.LastWireguardHandshake), + LocalIceCandidateType: p.LocalIceCandidateType, + RemoteIceCandidateType: p.RemoteIceCandidateType, + LocalIceCandidateEndpoint: p.LocalIceCandidateEndpoint, + RemoteIceCandidateEndpoint: p.RemoteIceCandidateEndpoint, } peerInfos[n] = pi } @@ -428,7 +563,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 @@ -436,10 +575,6 @@ func (c *Client) RemoveConnectionListener() { c.recorder.RemoveConnectionListener() } -func (c *Client) toggleRoute(command routeCommand) error { - return command.toggleRoute() -} - func (c *Client) getRouteManager() (routemanager.Manager, error) { client := c.getConnectClient() if client == nil { @@ -459,22 +594,22 @@ func (c *Client) getRouteManager() (routemanager.Manager, error) { return manager, nil } -func (c *Client) SelectRoute(route string) error { +func (c *Client) SelectRoute(id string) error { manager, err := c.getRouteManager() if err != nil { return err } - return c.toggleRoute(selectRouteCommand{route: route, manager: manager}) + return manager.SelectRoutes([]route.NetID{route.NetID(id)}, true) } -func (c *Client) DeselectRoute(route string) error { +func (c *Client) DeselectRoute(id string) error { manager, err := c.getRouteManager() if err != nil { return err } - return c.toggleRoute(deselectRouteCommand{route: route, manager: manager}) + return manager.DeselectRoutes([]route.NetID{route.NetID(id)}) } // getNetworkDomainsFromRoute extracts domains from a route and enriches each domain @@ -509,3 +644,28 @@ func exportEnvList(list *EnvList) { } } } + +// formatDuration renders a duration for display, trimming the fractional part +// to two digits so latencies read as "12.34ms" rather than "12.345678ms". +func formatDuration(d time.Duration) string { + ds := d.String() + dotIndex := strings.Index(ds, ".") + if dotIndex == -1 { + return ds + } + + endIndex := min(dotIndex+3, len(ds)) + + // Skip the remaining digits so only the unit suffix is appended back. + unitStart := endIndex + for unitStart < len(ds) && ds[unitStart] >= '0' && ds[unitStart] <= '9' { + unitStart++ + } + return ds[:endIndex] + ds[unitStart:] +} + +// formatTime renders a timestamp in UTC using a fixed layout. The zero time is +// passed through as-is so the UI can recognise it and show "never" instead. +func formatTime(t time.Time) string { + return t.UTC().Format("2006-01-02 15:04:05") +} diff --git a/client/android/client_mdm.go b/client/android/client_mdm.go new file mode 100644 index 000000000..d043b85d3 --- /dev/null +++ b/client/android/client_mdm.go @@ -0,0 +1,52 @@ +//go:build android + +package android + +import ( + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" +) + +type mdmSource struct { + loader *mdm.Loader + detector *mdm.ChangeDetector +} + +// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on +// this Client; passing nil disables MDM enforcement. +func (c *Client) SetMDMPolicyFetcher(p PolicyFetcher) { + loader := loaderFor(p) + c.mdmSource.Store(&mdmSource{loader: loader, detector: mdm.NewChangeDetector(loader)}) +} + +// HasMDMPolicyChanged re-reads the managed configuration and reports whether +// it changed since the last observation; call it from the native OS-change +// notification and restart the engine only on true. +func (c *Client) HasMDMPolicyChanged() bool { + src := c.mdmSource.Load() + if src == nil { + return false + } + return src.detector.Changed() +} + +// GetRestrictionsJSON returns the UI enforcement snapshot derived from the +// active MDM policy, in the JSON shape shared with the desktop frontend. +func (c *Client) GetRestrictionsJSON() (string, error) { + return mdm.BuildRestrictions(c.mdmLoader().Load()).JSON() +} + +func (c *Client) applyMDMOverlay(cfg *profilemanager.Config) { + loader := c.mdmLoader() + if cfg == nil || loader == nil { + return + } + cfg.ApplyMDMPolicy(loader.Load()) +} + +func (c *Client) mdmLoader() *mdm.Loader { + if src := c.mdmSource.Load(); src != nil { + return src.loader + } + return nil +} diff --git a/client/android/connection_listener.go b/client/android/connection_listener.go new file mode 100644 index 000000000..77c47574b --- /dev/null +++ b/client/android/connection_listener.go @@ -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)) +} diff --git a/client/android/login.go b/client/android/login.go index a9422cdbf..155c6eadd 100644 --- a/client/android/login.go +++ b/client/android/login.go @@ -4,8 +4,12 @@ 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/mdm" + "github.com/netbirdio/netbird/client/mobile" "github.com/netbirdio/netbird/client/system" ) @@ -36,15 +40,31 @@ type Auth struct { } // NewAuth instantiate Auth struct and validate the management URL -func NewAuth(cfgPath string, mgmURL string) (*Auth, error) { - inputCfg := profilemanager.ConfigInput{ - ManagementURL: mgmURL, +// +// 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". +// +// Auth is constructed under the active MDM policy: the policy is overlaid on +// the resolved config so the login runs against the enforced values, while +// the persisted config keeps the caller-supplied ones; a caller-supplied +// management URL is ignored while MDM manages that key. A nil fetcher +// disables MDM enforcement. +func NewAuth(cfgPath string, mgmURL string, fetcher PolicyFetcher) (*Auth, error) { + policy := loaderFor(fetcher).Load() + inputCfg := profilemanager.ConfigInput{ConfigPath: cfgPath} + if _, managed := policy.GetString(mdm.KeyManagementURL); !managed { + inputCfg.ManagementURL = mgmURL } - cfg, err := profilemanager.CreateInMemoryConfig(inputCfg) + cfg, err := profilemanager.UpdateOrCreateConfig(inputCfg) if err != nil { return nil, err } + cfg.ApplyMDMPolicy(policy) return &Auth{ ctx: context.Background(), @@ -53,17 +73,18 @@ 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, } } -// SaveConfigIfSSOSupported test the connectivity with the management server by retrieving the server device flow info. -// If it returns a flow info than save the configuration and return true. If it gets a codes.NotFound, it means that SSO -// is not supported and returns false without saving the configuration. For other errors return false. +// SaveConfigIfSSOSupported reports whether the management server supports SSO; the config is already persisted by NewAuth. func (a *Auth) SaveConfigIfSSOSupported(listener SSOListener) { go func() { sso, err := a.saveConfigIfSSOSupported() @@ -87,15 +108,10 @@ func (a *Auth) saveConfigIfSSOSupported() (bool, error) { return false, fmt.Errorf("failed to check SSO support: %v", err) } - if !supportsSSO { - return false, nil - } - - err = profilemanager.WriteOutConfig(a.cfgPath, a.config) - return true, err + return supportsSSO, nil } -// LoginWithSetupKeyAndSaveConfig test the connectivity with the management server with the setup key. +// LoginWithSetupKeyAndSaveConfig registers the peer with the setup key; the config is already persisted by NewAuth. func (a *Auth) LoginWithSetupKeyAndSaveConfig(resultListener ErrListener, setupKey string, deviceName string) { go func() { err := a.loginWithSetupKeyAndSaveConfig(setupKey, deviceName) @@ -120,8 +136,7 @@ func (a *Auth) loginWithSetupKeyAndSaveConfig(setupKey string, deviceName string if err != nil { return fmt.Errorf("login failed: %v", err) } - - return profilemanager.WriteOutConfig(a.cfgPath, a.config) + return nil } // Login try register the client on the server @@ -150,12 +165,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 +180,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 := mobile.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 mobile.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 diff --git a/client/android/login_test.go b/client/android/login_test.go new file mode 100644 index 000000000..130a846fc --- /dev/null +++ b/client/android/login_test.go @@ -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", nil) + 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", nil) + 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", nil) + 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) + } +} diff --git a/client/android/mdm.go b/client/android/mdm.go new file mode 100644 index 000000000..617d8f7cb --- /dev/null +++ b/client/android/mdm.go @@ -0,0 +1,19 @@ +package android + +import ( + "github.com/netbirdio/netbird/client/mdm" +) + +// PolicyFetcher is implemented by the native layer to return the current +// managed configuration as a JSON-encoded object string; "" means no MDM +// source is present. +type PolicyFetcher interface { + FetchJSON() string +} + +func loaderFor(p PolicyFetcher) *mdm.Loader { + if p == nil { + return mdm.NewJSONLoader(nil) + } + return mdm.NewJSONLoader(p.FetchJSON) +} diff --git a/client/android/peer_notifier.go b/client/android/peer_notifier.go index c2595e574..f525055bb 100644 --- a/client/android/peer_notifier.go +++ b/client/android/peer_notifier.go @@ -12,12 +12,30 @@ const ( ) // PeerInfo describe information about the peers. It designed for the UI usage +// +// The fields below ConnStatus back the peer detail screen. Durations and times +// are pre-formatted into strings so the UI does not have to know Go's layouts; +// Latency is additionally exposed as LatencyMs for colour coding. type PeerInfo struct { IP string IPv6 string FQDN string ConnStatus int Routes PeerRoutes + + PubKey string + Latency string + LatencyMs int64 + BytesRx int64 + BytesTx int64 + ConnStatusUpdate string + Relayed bool + RosenpassEnabled bool + LastWireguardHandshake string + LocalIceCandidateType string + RemoteIceCandidateType string + LocalIceCandidateEndpoint string + RemoteIceCandidateEndpoint string } func (p *PeerInfo) GetPeerRoutes() *PeerRoutes { diff --git a/client/android/preferences.go b/client/android/preferences.go index 066477293..5ce31026c 100644 --- a/client/android/preferences.go +++ b/client/android/preferences.go @@ -1,12 +1,16 @@ package android import ( + "sync/atomic" + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" ) // Preferences exports a subset of the internal config for gomobile type Preferences struct { configInput profilemanager.ConfigInput + mdmLoader atomic.Pointer[mdm.Loader] } // NewPreferences creates a new Preferences instance @@ -14,11 +18,30 @@ func NewPreferences(configPath string) *Preferences { ci := profilemanager.ConfigInput{ ConfigPath: configPath, } - return &Preferences{ci} + return &Preferences{configInput: ci} +} + +// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on +// this Preferences instance; passing nil disables MDM enforcement. +func (p *Preferences) SetMDMPolicyFetcher(f PolicyFetcher) { + p.mdmLoader.Store(loaderFor(f)) +} + +// GetRestrictionsJSON returns the UI enforcement snapshot derived from the +// active MDM policy, in the JSON shape shared with the desktop frontend. +func (p *Preferences) GetRestrictionsJSON() (string, error) { + return mdm.BuildRestrictions(p.policy()).JSON() +} + +func (p *Preferences) policy() *mdm.Policy { + return p.mdmLoader.Load().Load() } // GetManagementURL reads URL from config file func (p *Preferences) GetManagementURL() (string, error) { + if v, ok := p.policy().GetString(mdm.KeyManagementURL); ok { + return mdm.CanonicalURL(v), nil + } if p.configInput.ManagementURL != "" { return p.configInput.ManagementURL, nil } @@ -27,7 +50,7 @@ func (p *Preferences) GetManagementURL() (string, error) { if err != nil { return "", err } - return cfg.ManagementURL.String(), err + return cfg.ManagementURL.String(), nil } // SetManagementURL stores the given URL and waits for commit @@ -53,17 +76,21 @@ func (p *Preferences) SetAdminURL(url string) { p.configInput.AdminURL = url } -// GetPreSharedKey reads pre-shared key from config file -func (p *Preferences) GetPreSharedKey() (string, error) { +// HasPreSharedKey reports whether a pre-shared key is staged, persisted, or +// enforced by MDM; the key itself is never handed to the native layer. +func (p *Preferences) HasPreSharedKey() (bool, error) { + if _, ok := p.policy().GetString(mdm.KeyPreSharedKey); ok { + return true, nil + } if p.configInput.PreSharedKey != nil { - return *p.configInput.PreSharedKey, nil + return *p.configInput.PreSharedKey != "", nil } cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) if err != nil { - return "", err + return false, err } - return cfg.PreSharedKey, err + return cfg.PreSharedKey != "", nil } // SetPreSharedKey stores the given key and waits for commit @@ -78,6 +105,9 @@ func (p *Preferences) SetRosenpassEnabled(enabled bool) { // GetRosenpassEnabled reads Rosenpass enabled status from config file func (p *Preferences) GetRosenpassEnabled() (bool, error) { + if v, ok := p.policy().GetBool(mdm.KeyRosenpassEnabled); ok { + return v, nil + } if p.configInput.RosenpassEnabled != nil { return *p.configInput.RosenpassEnabled, nil } @@ -96,6 +126,9 @@ func (p *Preferences) SetRosenpassPermissive(permissive bool) { // GetRosenpassPermissive reads Rosenpass permissive setting from config file func (p *Preferences) GetRosenpassPermissive() (bool, error) { + if v, ok := p.policy().GetBool(mdm.KeyRosenpassPermissive); ok { + return v, nil + } if p.configInput.RosenpassPermissive != nil { return *p.configInput.RosenpassPermissive, nil } @@ -109,6 +142,9 @@ func (p *Preferences) GetRosenpassPermissive() (bool, error) { // GetDisableClientRoutes reads disable client routes setting from config file func (p *Preferences) GetDisableClientRoutes() (bool, error) { + if v, ok := p.policy().GetBool(mdm.KeyDisableClientRoutes); ok { + return v, nil + } if p.configInput.DisableClientRoutes != nil { return *p.configInput.DisableClientRoutes, nil } @@ -127,6 +163,9 @@ func (p *Preferences) SetDisableClientRoutes(disable bool) { // GetDisableServerRoutes reads disable server routes setting from config file func (p *Preferences) GetDisableServerRoutes() (bool, error) { + if v, ok := p.policy().GetBool(mdm.KeyDisableServerRoutes); ok { + return v, nil + } if p.configInput.DisableServerRoutes != nil { return *p.configInput.DisableServerRoutes, nil } @@ -181,6 +220,9 @@ func (p *Preferences) SetDisableFirewall(disable bool) { // GetServerSSHAllowed reads server SSH allowed setting from config file func (p *Preferences) GetServerSSHAllowed() (bool, error) { + if v, ok := p.policy().GetBool(mdm.KeyAllowServerSSH); ok { + return v, nil + } if p.configInput.ServerSSHAllowed != nil { return *p.configInput.ServerSSHAllowed, nil } @@ -291,6 +333,9 @@ func (p *Preferences) SetEnableSSHRemotePortForwarding(enabled bool) { // GetBlockInbound reads block inbound setting from config file func (p *Preferences) GetBlockInbound() (bool, error) { + if v, ok := p.policy().GetBool(mdm.KeyBlockInbound); ok { + return v, nil + } if p.configInput.BlockInbound != nil { return *p.configInput.BlockInbound, nil } @@ -325,8 +370,34 @@ func (p *Preferences) SetDisableIPv6(disable bool) { p.configInput.DisableIPv6 = &disable } +// GetRemoteJobsAllowed reads the remote jobs opt-in from config file +func (p *Preferences) GetRemoteJobsAllowed() (bool, error) { + policy := p.policy() + if !policy.HasKey(mdm.KeyRemoteJobsAllowed) && p.configInput.RemoteJobsAllowed != nil { + return *p.configInput.RemoteJobsAllowed, nil + } + + cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + if err != nil { + return false, err + } + cfg.ApplyMDMPolicy(policy) + if cfg.RemoteJobsAllowed == nil { + return false, nil + } + return *cfg.RemoteJobsAllowed, nil +} + +// SetRemoteJobsAllowed stores the given value and waits for commit +func (p *Preferences) SetRemoteJobsAllowed(allowed bool) { + p.configInput.RemoteJobsAllowed = &allowed +} + // Commit writes out the changes to the config file func (p *Preferences) Commit() error { + if err := profilemanager.CheckMDMConflicts(p.configInput, p.policy()); err != nil { + return err + } _, err := profilemanager.UpdateOrCreateConfig(p.configInput) return err } diff --git a/client/android/preferences_test.go b/client/android/preferences_test.go index 2bbccef86..d9f5b1918 100644 --- a/client/android/preferences_test.go +++ b/client/android/preferences_test.go @@ -28,14 +28,13 @@ func TestPreferences_DefaultValues(t *testing.T) { t.Errorf("invalid default management url: %s", defaultVar) } - var preSharedKey string - preSharedKey, err = p.GetPreSharedKey() + hasPSK, err := p.HasPreSharedKey() if err != nil { - t.Fatalf("failed to read default preshared key: %s", err) + t.Fatalf("failed to read default preshared key presence: %s", err) } - if preSharedKey != "" { - t.Errorf("invalid preshared key: %s", preSharedKey) + if hasPSK { + t.Errorf("unexpected preshared key presence on fresh config") } } @@ -65,13 +64,13 @@ func TestPreferences_ReadUncommitedValues(t *testing.T) { } p.SetPreSharedKey(exampleString) - resp, err = p.GetPreSharedKey() + hasPSK, err := p.HasPreSharedKey() if err != nil { - t.Fatalf("failed to read preshared key: %s", err) + t.Fatalf("failed to read preshared key presence: %s", err) } - if resp != exampleString { - t.Errorf("unexpected preshared key: %s", resp) + if !hasPSK { + t.Errorf("expected preshared key presence after staging one") } } @@ -109,12 +108,12 @@ func TestPreferences_Commit(t *testing.T) { t.Errorf("unexpected management url: %s", resp) } - resp, err = p.GetPreSharedKey() + hasPSK, err := p.HasPreSharedKey() if err != nil { - t.Fatalf("failed to read preshared key: %s", err) + t.Fatalf("failed to read preshared key presence: %s", err) } - if resp != examplePresharedKey { - t.Errorf("unexpected preshared key: %s", resp) + if !hasPSK { + t.Errorf("expected preshared key presence after commit") } } diff --git a/client/android/profile_manager.go b/client/android/profile_manager.go index 87c001396..4bc60c453 100644 --- a/client/android/profile_manager.go +++ b/client/android/profile_manager.go @@ -3,42 +3,37 @@ package android import ( - "fmt" - "os" - "path/filepath" - - log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mobile" ) 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) + // Android uses a single user context per app. androidUsername = "android" ) -// Profile represents a profile for gomobile +// 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 client/mobile/profile_state.go. + Email string IsActive bool } -// ProfileArray wraps profiles for gomobile compatibility +// ProfileArray wraps profiles for gomobile compatibility (gomobile cannot +// bind Go slices directly). type ProfileArray struct { items []*Profile } -// Length returns the number of profiles +// Length returns the number of profiles. func (p *ProfileArray) Length() int { return len(p.items) } -// Get returns the profile at index i +// Get returns the profile at index i, or nil if out of range. func (p *ProfileArray) Get(i int) *Profile { if i < 0 || i >= len(p.items) { return nil @@ -46,214 +41,104 @@ func (p *ProfileArray) Get(i int) *Profile { return p.items[i] } -/* - -/data/data/io.netbird.client/files/ ← configDir parameter -├── netbird.cfg ← Default profile config -├── state.json ← Default profile state -├── active_profile.json ← Active profile tracker (JSON with Name + Username) -└── profiles/ ← Subdirectory for non-default profiles - ├── work.json ← Legacy work profile config - ├── work.state.json ← Legacy work profile state - ├── 4c5f5c8198c3989cffb5b5394f5a7ae0.json ← ID profile config - ├── 4c5f5c8198c3989cffb5b5394f5a7ae0.state.json ← ID profile state -*/ - -// ProfileManager manages profiles for Android -// It wraps the internal profilemanager to provide Android-specific behavior +// ProfileManager adapts the shared mobile profile manager (client/mobile) to +// gomobile-friendly types. See that package for the on-disk layout and +// semantics. type ProfileManager struct { - configDir string - serviceMgr *profilemanager.ServiceManager + impl *mobile.ProfileManager } -// NewProfileManager creates a new profile manager for Android +// NewProfileManager creates a new profile manager for Android. configDir is +// the app's files directory. func NewProfileManager(configDir string) *ProfileManager { - // Set the default config path for Android (stored in root configDir, not profiles/) - defaultConfigPath := filepath.Join(configDir, defaultConfigFilename) - - // Set global paths for Android - profilemanager.DefaultConfigPathDir = configDir - profilemanager.DefaultConfigPath = defaultConfigPath - profilemanager.ActiveProfileStatePath = filepath.Join(configDir, "active_profile.json") - - // Create ServiceManager with profiles/ subdirectory - // This avoids modifying the global ConfigDirOverride for profile listing - profilesDir := filepath.Join(configDir, profilesSubdir) - serviceMgr := profilemanager.NewServiceManagerWithProfilesDir(defaultConfigPath, profilesDir) - - return &ProfileManager{ - configDir: configDir, - serviceMgr: serviceMgr, - } + return &ProfileManager{impl: mobile.NewProfileManager(configDir, androidUsername)} } -// ListProfiles returns all available profiles +// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on +// this ProfileManager; passing nil disables MDM enforcement. +func (pm *ProfileManager) SetMDMPolicyFetcher(f PolicyFetcher) { + pm.impl.SetMDMLoader(loaderFor(f)) +} + +// ListProfiles returns all available profiles, including the default profile, +// with their active status set. func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) { - // Use ServiceManager (looks in profiles/ directory, checks active_profile.json for IsActive) - internalProfiles, err := pm.serviceMgr.ListProfiles(androidUsername) + profiles, err := pm.impl.ListProfiles() if err != nil { - return nil, fmt.Errorf("failed to list profiles: %w", err) + return nil, err } - // Convert internal profiles to Android Profile type - var profiles []*Profile - for _, p := range internalProfiles { - profiles = append(profiles, &Profile{ - ID: p.ID.String(), - Name: p.Name, - IsActive: p.IsActive, - }) + items := make([]*Profile, 0, len(profiles)) + for i := range profiles { + items = append(items, fromMobileProfile(&profiles[i])) } - - return &ProfileArray{items: profiles}, nil + return &ProfileArray{items: items}, nil } -// GetActiveProfile returns the currently active profile name +// GetActiveProfile returns the currently active profile. func (pm *ProfileManager) GetActiveProfile() (*Profile, error) { - // Use ServiceManager to stay consistent with ListProfiles - // ServiceManager uses active_profile.json - activeState, err := pm.serviceMgr.GetActiveProfileState() + p, err := pm.impl.GetActiveProfile() if err != nil { - return nil, fmt.Errorf("failed to get active profile: %w", err) + return nil, err } - - // ActiveProfileState only stores the ID (and username), not the display - // name. Resolve the ID to the full profile so callers get the real Name. - prof, err := pm.serviceMgr.ResolveProfile(activeState.ID.String(), androidUsername) - 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 fromMobileProfile(p), nil } -// SwitchProfile switches to a different profile +// SwitchProfile records the given profile ID as the active profile. The caller +// must stop the VPN tunnel before switching. func (pm *ProfileManager) SwitchProfile(id string) error { - // Use ServiceManager to stay consistent with ListProfiles - // ServiceManager uses active_profile.json - err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{ - ID: profilemanager.ID(id), - Username: androidUsername, - }) - if err != nil { - return fmt.Errorf("failed to switch profile: %w", err) - } - - log.Infof("switched to profile: %s", id) - return nil + return pm.impl.SwitchProfile(id) } -// AddProfile creates a new profile +// AddProfile creates a new profile with the given display name and a +// generated ID. func (pm *ProfileManager) AddProfile(profileName string) error { - // Use ServiceManager (creates profile in profiles/ directory) - profile, err := pm.serviceMgr.AddProfile(profileName, androidUsername) - if err != nil { - return fmt.Errorf("failed to add profile: %w", err) - } - - log.Infof("created new profile: %s", profile.ID) - return nil + _, err := pm.impl.AddProfile(profileName) + return err } -// LogoutProfile logs out from a profile (clears authentication) +// RenameProfile changes the display name of the profile identified by id. The +// on-disk filename (the ID) is left unchanged. +func (pm *ProfileManager) RenameProfile(id string, newName string) error { + return pm.impl.RenameProfile(id, newName) +} + +// LogoutProfile clears authentication data for a profile, forcing a re-login. +// The management URL and other settings are preserved. func (pm *ProfileManager) LogoutProfile(id string) error { - configPath, err := pm.getProfileConfigPath(id) - if err != nil { - return err - } - - if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) { - return fmt.Errorf("id '%s' is not valid", id) - } - - // Check if profile exists - if _, err := os.Stat(configPath); os.IsNotExist(err) { - return fmt.Errorf("profile '%s' does not exist", id) - } - - // Read current config using internal profilemanager - config, err := profilemanager.ReadConfig(configPath) - if err != nil { - return fmt.Errorf("failed to read profile config: %w", err) - } - - // Clear authentication by removing private key and SSH key - config.PrivateKey = "" - config.SSHKey = "" - - // Save config using internal profilemanager - if err := profilemanager.WriteOutConfig(configPath, config); err != nil { - return fmt.Errorf("failed to save config: %w", err) - } - - log.Infof("logged out from profile: %s", id) - return nil + return pm.impl.LogoutProfile(id) } -// RemoveProfile deletes a profile +// RemoveProfile deletes a profile. The default profile and the active profile +// cannot be removed. func (pm *ProfileManager) RemoveProfile(id string) error { - // 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) - } - - log.Infof("removed profile: %s", id) - return nil + return pm.impl.RemoveProfile(id) } -// getProfileConfigPath returns the config file path for a profile -// This is needed for Android-specific path handling (netbird.cfg for default profile) -func (pm *ProfileManager) getProfileConfigPath(id string) (string, error) { - if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) { - return "", fmt.Errorf("id %q is not valid", id) - } - - if id == profilemanager.DefaultProfileName { - // Android uses netbird.cfg for default profile instead of default.json - // Default profile is stored in root configDir, not in profiles/ - return filepath.Join(pm.configDir, defaultConfigFilename), nil - } - - profilesDir := filepath.Join(pm.configDir, profilesSubdir) - return filepath.Join(profilesDir, id+".json"), nil -} - -// GetConfigPath returns the config file path for a given profile id -// Java should call this instead of constructing paths with Preferences.configFile() +// GetConfigPath returns the config file path for the given profile ID. Java +// should call this instead of constructing paths with Preferences.configFile(). func (pm *ProfileManager) GetConfigPath(id string) (string, error) { - return pm.getProfileConfigPath(id) + return pm.impl.GetConfigPath(id) } -// GetStateFilePath returns the state file path for a given profile -// Java should call this instead of constructing paths with Preferences.stateFile() +// GetStateFilePath returns the state file path for the given profile ID. Java +// should call this instead of constructing paths with Preferences.stateFile(). func (pm *ProfileManager) GetStateFilePath(id string) (string, error) { - if id == "" || id == profilemanager.DefaultProfileName { - return filepath.Join(pm.configDir, "state.json"), nil - } - - if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) { - return "", fmt.Errorf("id %q is not valid", id) - } - - profilesDir := filepath.Join(pm.configDir, profilesSubdir) - return filepath.Join(profilesDir, id+".state.json"), nil + return pm.impl.GetStateFilePath(id) } -// GetActiveConfigPath returns the config file path for the currently active profile -// Java should call this instead of Preferences.getActiveProfileName() + Preferences.configFile() +// GetActiveConfigPath returns the config file path for the currently active +// profile. func (pm *ProfileManager) GetActiveConfigPath() (string, error) { - activeProfile, err := pm.GetActiveProfile() - if err != nil { - return "", fmt.Errorf("failed to get active profile: %w", err) - } - return pm.GetConfigPath(activeProfile.ID) + return pm.impl.GetActiveConfigPath() } -// GetActiveStateFilePath returns the state file path for the currently active profile -// Java should call this instead of Preferences.getActiveProfileName() + Preferences.stateFile() +// GetActiveStateFilePath returns the state file path for the currently active +// profile. func (pm *ProfileManager) GetActiveStateFilePath() (string, error) { - activeProfile, err := pm.GetActiveProfile() - if err != nil { - return "", fmt.Errorf("failed to get active profile: %w", err) - } - return pm.GetStateFilePath(activeProfile.ID) + return pm.impl.GetActiveStateFilePath() +} + +func fromMobileProfile(p *mobile.Profile) *Profile { + return &Profile{ID: p.ID, Name: p.Name, Email: p.Email, IsActive: p.IsActive} } diff --git a/client/android/profile_prefs.go b/client/android/profile_prefs.go new file mode 100644 index 000000000..a761ebbcf --- /dev/null +++ b/client/android/profile_prefs.go @@ -0,0 +1,37 @@ +//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") + } + prefs, err := NewProfileManager(configDir).impl.ProfilePrefs(profileID) + if err != nil { + return nil, 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) +} diff --git a/client/android/route_command.go b/client/android/route_command.go deleted file mode 100644 index 5e7357335..000000000 --- a/client/android/route_command.go +++ /dev/null @@ -1,70 +0,0 @@ -//go:build android - -package android - -import ( - "fmt" - - log "github.com/sirupsen/logrus" - "golang.org/x/exp/maps" - - "github.com/netbirdio/netbird/client/internal/routemanager" - "github.com/netbirdio/netbird/route" -) - -func executeRouteToggle(id string, manager routemanager.Manager, - operationName string, - routeOperation func(routes []route.NetID, allRoutes []route.NetID) error) error { - netID := route.NetID(id) - routes := []route.NetID{netID} - - routesMap := manager.GetClientRoutesWithNetID() - routes = route.ExpandV6ExitPairs(routes, routesMap) - - log.Debugf("%s with ids: %v", operationName, routes) - - if err := routeOperation(routes, maps.Keys(routesMap)); err != nil { - log.Debugf("error when %s: %s", operationName, err) - return fmt.Errorf("error %s: %w", operationName, err) - } - - manager.TriggerSelection(manager.GetClientRoutes()) - - return nil -} - -type routeCommand interface { - toggleRoute() error -} - -type selectRouteCommand struct { - route string - manager routemanager.Manager -} - -func (s selectRouteCommand) toggleRoute() error { - routeSelector := s.manager.GetRouteSelector() - if routeSelector == nil { - return fmt.Errorf("no route selector available") - } - - routeOperation := func(routes []route.NetID, allRoutes []route.NetID) error { - return routeSelector.SelectRoutes(routes, true, allRoutes) - } - - return executeRouteToggle(s.route, s.manager, "selecting route", routeOperation) -} - -type deselectRouteCommand struct { - route string - manager routemanager.Manager -} - -func (d deselectRouteCommand) toggleRoute() error { - routeSelector := d.manager.GetRouteSelector() - if routeSelector == nil { - return fmt.Errorf("no route selector available") - } - - return executeRouteToggle(d.route, d.manager, "deselecting route", routeSelector.DeselectRoutes) -} diff --git a/client/android/session.go b/client/android/session.go new file mode 100644 index 000000000..d5da09c93 --- /dev/null +++ b/client/android/session.go @@ -0,0 +1,312 @@ +//go:build android + +package android + +import ( + "context" + "fmt" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/client/internal/auth" + "github.com/netbirdio/netbird/client/internal/auth/sessionwatch" + "github.com/netbirdio/netbird/client/internal/peer" + cProto "github.com/netbirdio/netbird/client/proto" +) + +// StateChangeListener receives client state notifications. +// +// OnStateChanged is a payload-free wake-up whenever the state snapshot +// changed: connection state, the run-loop status label (e.g. NeedsLogin) or +// the session deadline. It mirrors the daemon's SubscribeStatus stream +// trigger — on each signal the consumer pulls the fresh values via +// Status() / SessionExpiresAtUnix(). +// +// OnSessionExpiring forwards the engine's session-expiry warnings, fired at +// sessionwatch.WarningLead before the deadline and again at FinalWarningLead +// (finalWarning true). The second one is suppressed when the user dismissed +// the first via DismissSessionWarning. The daemon turns the same events into +// its tray notification. +type StateChangeListener interface { + OnStateChanged() + OnSessionExpiring(expiresAtUnix int64, leadMinutes int64, finalWarning bool) +} + +// Status returns the connect run-loop's status label — the same value the +// desktop daemon serves in StatusResponse.Status. "NeedsLogin" means the +// management server rejected the peer and an interactive login is required. +// +// The label is latched: the run loop keeps its status in a per-run context +// state, which a restart replaces with a fresh Idle one, so an engine restart +// (network change, always-on) would otherwise erase the fact that the peer +// still needs to log in. Only a successful interactive login or extend clears +// it — see clearLoginRequired. +func (c *Client) Status() string { + latched, generation := c.loginRequiredState() + if latched { + return string(internal.StatusNeedsLogin) + } + cc := c.getConnectClient() + if cc == nil { + return string(internal.StatusIdle) + } + status := cc.Status() + if status == internal.StatusNeedsLogin { + c.latchLoginRequired(generation) + } + return string(status) +} + +func (c *Client) loginRequiredState() (bool, uint64) { + c.loginRequiredMu.Lock() + defer c.loginRequiredMu.Unlock() + return c.loginRequired, c.loginCleared +} + +// latchLoginRequired records a NeedsLogin observation, unless a clear landed +// while the caller was reading the run loop's status: cc.Status() is read +// outside the lock, so a login or extend completing in that window would +// otherwise be undone by this stale observation, stranding the UI on +// "login required" over a healthy session. +func (c *Client) latchLoginRequired(observedGeneration uint64) { + c.loginRequiredMu.Lock() + defer c.loginRequiredMu.Unlock() + if c.loginCleared != observedGeneration { + return + } + c.loginRequired = true +} + +// clearLoginRequired releases the latch after a successful interactive login +// or session extend, and invalidates any observation already in flight. +func (c *Client) clearLoginRequired() { + c.loginRequiredMu.Lock() + defer c.loginRequiredMu.Unlock() + c.loginRequired = false + c.loginCleared++ +} + +// SessionExpiresAtUnix returns the SSO session deadline as unix seconds, or 0 +// when no deadline is known (not SSO-registered, expiry disabled, or the +// engine has not received one yet). A past value means the session expired. +// Mirror of StatusResponse.sessionExpiresAt on the desktop daemon. +func (c *Client) SessionExpiresAtUnix() int64 { + deadline := c.recorder.GetSessionExpiresAt() + if deadline.IsZero() { + return 0 + } + return deadline.Unix() +} + +// SetStateChangeListener registers the state notification listener. +// Replaces any previously registered listener; remove it with +// RemoveStateChangeListener. +func (c *Client) SetStateChangeListener(listener StateChangeListener) { + c.stateChangeMu.Lock() + defer c.stateChangeMu.Unlock() + c.stopStateChangeWatchLocked() + if listener == nil { + return + } + + // Both subscriptions are buffered (one pending tick, ten pending events), + // so unsubscribing is not enough to stop callbacks: the loops would drain + // what is already queued and deliver it to a listener the caller has + // already removed or replaced. Gate every callback on this registration's + // own signal, which is closed before unsubscribing. + done := make(chan struct{}) + c.stateChangeDone = done + + id, ch := c.recorder.SubscribeToStateChanges() + c.stateChangeSubID = id + // The channel is closed by UnsubscribeFromStateChanges, which ends the + // goroutine. Ticks are coalesced (buffer of one), so a burst of changes + // wakes the listener once. + go func() { + for range ch { + select { + case <-done: + return + default: + } + listener.OnStateChanged() + } + }() + + c.eventSub = c.recorder.SubscribeToEvents() + go watchSessionWarnings(c.eventSub, listener, done) +} + +// RemoveStateChangeListener unregisters the state notification listener. +func (c *Client) RemoveStateChangeListener() { + c.stateChangeMu.Lock() + defer c.stateChangeMu.Unlock() + c.stopStateChangeWatchLocked() +} + +// DismissSessionWarning records the user's "Dismiss" on the first expiry +// warning and suppresses the final one for the current deadline. A refreshed +// deadline re-arms both. No-op while the engine is not running. +func (c *Client) DismissSessionWarning() { + cc := c.getConnectClient() + if cc == nil { + return + } + engine := cc.Engine() + if engine == nil { + return + } + engine.DismissSessionWarning() +} + +// ExtendAuthSession runs the interactive SSO flow to obtain a fresh JWT and +// asks the management server to extend the session deadline. The tunnel is +// untouched: no resync, no reconnect. Async; the result arrives on the +// listener. Mirror of the daemon's RequestExtendAuthSession / +// WaitExtendAuthSession RPC pair, with URLOpener playing the "UI opens the +// browser" role. +// +// Only one flow may be in flight: the PKCE step binds a fixed loopback port, +// so a second concurrent flow would fail on that bind. Call +// CancelExtendAuthSession when the user abandons the browser. +func (c *Client) ExtendAuthSession(urlOpener URLOpener, isAndroidTV bool, resultListener ErrListener) { + ctx, err := c.beginExtend() + if err != nil { + resultListener.OnError(err) + return + } + + go func() { + defer c.endExtend() + if err := c.extendAuthSession(ctx, urlOpener, isAndroidTV); err != nil { + resultListener.OnError(err) + return + } + resultListener.OnSuccess() + }() +} + +// CancelExtendAuthSession aborts an in-flight ExtendAuthSession. The tunnel is +// left alone — unlike the login flow, which cancels the whole client context +// by stopping the engine. Without this the abandoned PKCE wait keeps its +// loopback port for the full flow timeout and blocks every later attempt. +// No-op when no flow is running. +func (c *Client) CancelExtendAuthSession() { + c.extendMu.Lock() + defer c.extendMu.Unlock() + if c.extendCancel != nil { + c.extendCancel() + } +} + +func (c *Client) stopStateChangeWatchLocked() { + // Signal first, unsubscribe second: closing the channels only stops new + // items, and the loops would still hand whatever is buffered to a listener + // that is no longer registered. + if c.stateChangeDone != nil { + close(c.stateChangeDone) + c.stateChangeDone = nil + } + if c.stateChangeSubID != "" { + c.recorder.UnsubscribeFromStateChanges(c.stateChangeSubID) + c.stateChangeSubID = "" + } + if c.eventSub != nil { + // Closes the channel, which ends watchSessionWarnings. + c.recorder.UnsubscribeFromEvents(c.eventSub) + c.eventSub = nil + } +} + +// watchSessionWarnings forwards the engine's session-expiry warnings to the +// listener. The event stream also carries unrelated traffic — network-map +// updates on every sync, DNS and route errors — so everything but an +// AUTHENTICATION event carrying the session-warning marker is dropped. Exits +// when the subscription is closed by UnsubscribeFromEvents, or earlier when +// done is closed — the stream buffers up to ten events, and a deregistered +// listener must not receive the ones already queued. +func watchSessionWarnings(sub *peer.EventSubscription, listener StateChangeListener, done <-chan struct{}) { + for ev := range sub.Events() { + select { + case <-done: + return + default: + } + if ev.GetCategory() != cProto.SystemEvent_AUTHENTICATION { + continue + } + meta := ev.GetMetadata() + if meta[sessionwatch.MetaSessionWarning] != "true" { + // Other AUTHENTICATION events exist (e.g. a deadline rejected as + // out of range); they carry no warning marker. + continue + } + deadline, err := sessionwatch.ParseExpiresAt(meta[sessionwatch.MetaSessionExpiresAt]) + if err != nil { + log.Warnf("session warning event with unparsable deadline: %v", err) + continue + } + lead, err := sessionwatch.ParseLeadMinutes(meta[sessionwatch.MetaSessionLeadMinutes]) + if err != nil { + // Informational only — the deadline above is what drives the UI. + lead = 0 + } + listener.OnSessionExpiring(deadline.Unix(), int64(lead), + meta[sessionwatch.MetaSessionFinal] == "true") + } +} + +func (c *Client) beginExtend() (context.Context, error) { + c.extendMu.Lock() + defer c.extendMu.Unlock() + if c.extendCancel != nil { + return nil, fmt.Errorf("session extend already in progress") + } + ctx, cancel := context.WithCancel(context.Background()) + c.extendCancel = cancel + return ctx, nil +} + +func (c *Client) endExtend() { + c.extendMu.Lock() + defer c.extendMu.Unlock() + if c.extendCancel != nil { + c.extendCancel() + c.extendCancel = nil + } +} + +func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isAndroidTV bool) error { + cfg, cfgPath, cc := c.authSnapshot() + if cfg == nil || cc == nil { + return fmt.Errorf("engine is not running") + } + engine := cc.Engine() + if engine == nil { + return fmt.Errorf("engine is not initialized") + } + + authClient, err := auth.NewAuth(ctx, cfg.PrivateKey, cfg.ManagementURL, cfg) + if err != nil { + return fmt.Errorf("failed to create auth client: %v", err) + } + defer authClient.Close() + + // 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) + } + + if _, err := engine.ExtendAuthSession(ctx, tokenInfo.GetTokenToUse()); err != nil { + return err + } + c.clearLoginRequired() + + go urlOpener.OnLoginSuccess() + return nil +} diff --git a/client/android/split_tunnel.go b/client/android/split_tunnel.go new file mode 100644 index 000000000..60e1ceeb8 --- /dev/null +++ b/client/android/split_tunnel.go @@ -0,0 +1,125 @@ +package android + +// SplitTunnelMode is which of the two selections, if either, the tunnel applies. +// Its values land in the profile's stored preferences, so the constants below +// are append-only and must never be reordered. +type SplitTunnelMode int + +const ( + modeOff SplitTunnelMode = iota + modeExclude + modeInclude +) + +// The same modes as basic ints. gomobile drops a constant whose type is not a +// basic one, so these are what reaches the generated Java bindings, and they +// keep the Android side tied to the values above instead of repeating 0, 1, 2. +const ( + SplitTunnelModeOff = int(modeOff) + SplitTunnelModeExclude = int(modeExclude) + SplitTunnelModeInclude = int(modeInclude) +) + +type splitTunnelSection struct { + Mode SplitTunnelMode `json:"mode"` + Excluded []string `json:"excluded"` + Included []string `json:"included"` +} + +// PackageList wraps []string for gomobile compatibility. +type PackageList struct { + items []string +} + +// NewPackageList creates an empty list to fill via Add. +func NewPackageList() *PackageList { + return &PackageList{} +} + +// Add appends a package name, ignoring empty ones. +func (l *PackageList) Add(s string) { + if s == "" { + return + } + l.items = append(l.items, s) +} + +// Size returns the number of entries. +func (l *PackageList) Size() int { + return len(l.items) +} + +// Get returns the entry at index i, or an empty string when out of range. +func (l *PackageList) Get(i int) string { + if i < 0 || i >= len(l.items) { + return "" + } + return l.items[i] +} + +// SplitTunnelSettings is one profile's choice of which applications the tunnel +// carries. The two selections are kept apart because the platform applies one +// or the other and never both, and so that switching mode does not throw away +// the picks made in the other one. +// +// Mode is an int rather than a SplitTunnelMode because gomobile carries only +// basic types across the binding. It holds one of the SplitTunnelMode* +// constants. +type SplitTunnelSettings struct { + Mode int + Excluded *PackageList + Included *PackageList +} + +// NewSplitTunnelSettings creates settings that carry every application. +func NewSplitTunnelSettings() *SplitTunnelSettings { + return &SplitTunnelSettings{ + Mode: SplitTunnelModeOff, + Excluded: NewPackageList(), + Included: NewPackageList(), + } +} + +func packagesOf(list *PackageList) []string { + if list == nil { + return nil + } + out := make([]string, 0, len(list.items)) + out = append(out, list.items...) + return out +} + +// normalizeSplitTunnelMode maps anything outside the known set to off, so a mode +// written by a newer build degrades to carrying every application rather than to +// some other mode's behaviour. +func normalizeSplitTunnelMode(mode SplitTunnelMode) SplitTunnelMode { + switch mode { + case modeExclude, modeInclude: + return mode + default: + return modeOff + } +} + +func settingsFromSection(section splitTunnelSection) *SplitTunnelSettings { + out := NewSplitTunnelSettings() + out.Mode = int(normalizeSplitTunnelMode(section.Mode)) + for _, pkg := range section.Excluded { + out.Excluded.Add(pkg) + } + for _, pkg := range section.Included { + out.Included.Add(pkg) + } + return out +} + +func sectionFromSettings(settings *SplitTunnelSettings) splitTunnelSection { + if settings == nil { + settings = NewSplitTunnelSettings() + } + return splitTunnelSection{ + Mode: normalizeSplitTunnelMode(SplitTunnelMode(settings.Mode)), + Excluded: packagesOf(settings.Excluded), + Included: packagesOf(settings.Included), + } +} diff --git a/client/android/split_tunnel_store.go b/client/android/split_tunnel_store.go new file mode 100644 index 000000000..f54e0c8ef --- /dev/null +++ b/client/android/split_tunnel_store.go @@ -0,0 +1,34 @@ +//go:build android + +package android + +const splitTunnelNamespace = "split-tunnel" + +// SplitTunnelStore reads and writes a profile's split tunnelling settings. +type SplitTunnelStore struct { + prefs prefsStore +} + +// NewSplitTunnelStore opens the split tunnelling store of the given profile. +func NewSplitTunnelStore(configDir, profileID string) (*SplitTunnelStore, error) { + prefs, err := newProfilePrefs(configDir, profileID) + if err != nil { + return nil, err + } + return &SplitTunnelStore{prefs: prefs}, nil +} + +// Load returns the stored settings, or settings that carry every application +// when the profile has none saved. +func (s *SplitTunnelStore) Load() (*SplitTunnelSettings, error) { + var section splitTunnelSection + if _, err := s.prefs.Get(splitTunnelNamespace, §ion); err != nil { + return nil, err + } + return settingsFromSection(section), nil +} + +// Save replaces the stored settings. +func (s *SplitTunnelStore) Save(settings *SplitTunnelSettings) error { + return s.prefs.Put(splitTunnelNamespace, sectionFromSettings(settings)) +} diff --git a/client/android/split_tunnel_test.go b/client/android/split_tunnel_test.go new file mode 100644 index 000000000..bf6e0267e --- /dev/null +++ b/client/android/split_tunnel_test.go @@ -0,0 +1,151 @@ +package android + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestNormalizeSplitTunnelMode(t *testing.T) { + tests := []struct { + name string + mode SplitTunnelMode + want SplitTunnelMode + }{ + {name: "exclude is kept", mode: modeExclude, want: modeExclude}, + {name: "include is kept", mode: modeInclude, want: modeInclude}, + {name: "off is kept", mode: modeOff, want: modeOff}, + {name: "a mode from a newer build falls back to off", mode: SplitTunnelMode(7), want: modeOff}, + {name: "a negative mode falls back to off", mode: SplitTunnelMode(-1), want: modeOff}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := normalizeSplitTunnelMode(tt.mode); got != tt.want { + t.Errorf("normalizeSplitTunnelMode(%d) = %d, want %d", tt.mode, got, tt.want) + } + }) + } +} + +// The constants the Android side reads must stay the values the store writes: +// gomobile carries the ints below, not the typed constants they mirror. +func TestSplitTunnelModeConstantsMirrorTheTypedOnes(t *testing.T) { + if SplitTunnelModeOff != int(modeOff) { + t.Errorf("off = %d, want %d", SplitTunnelModeOff, modeOff) + } + if SplitTunnelModeExclude != int(modeExclude) { + t.Errorf("exclude = %d, want %d", SplitTunnelModeExclude, modeExclude) + } + if SplitTunnelModeInclude != int(modeInclude) { + t.Errorf("include = %d, want %d", SplitTunnelModeInclude, modeInclude) + } +} + +func TestSettingsFromSection(t *testing.T) { + got := settingsFromSection(splitTunnelSection{ + Mode: modeExclude, + Excluded: []string{"com.example.a", "com.example.b"}, + Included: []string{"com.example.c"}, + }) + + if got.Mode != SplitTunnelModeExclude { + t.Errorf("mode = %d, want %d", got.Mode, SplitTunnelModeExclude) + } + if got.Excluded.Size() != 2 || got.Excluded.Get(0) != "com.example.a" { + t.Errorf("excluded = %v, want the two stored packages", packagesOf(got.Excluded)) + } + if got.Included.Size() != 1 || got.Included.Get(0) != "com.example.c" { + t.Errorf("included = %v, want the stored package", packagesOf(got.Included)) + } +} + +// A profile that has never stored anything decodes into an empty section, and +// must come back as settings that carry every application rather than as nil +// lists the caller would have to guard against. +func TestSettingsFromEmptySectionCarriesEverything(t *testing.T) { + got := settingsFromSection(splitTunnelSection{}) + + if got.Mode != SplitTunnelModeOff { + t.Errorf("mode = %d, want %d", got.Mode, SplitTunnelModeOff) + } + if got.Excluded == nil || got.Included == nil { + t.Fatal("both selections must be usable lists, not nil") + } + if got.Excluded.Size() != 0 || got.Included.Size() != 0 { + t.Errorf("selections = %v/%v, want both empty", packagesOf(got.Excluded), packagesOf(got.Included)) + } +} + +// The section is what the profile's preference file holds, so the mode has to +// survive a JSON round trip as the number the constants name. +func TestSectionEncodesTheModeAsItsNumber(t *testing.T) { + raw, err := json.Marshal(sectionFromSettings(&SplitTunnelSettings{Mode: SplitTunnelModeInclude})) + if err != nil { + t.Fatalf("marshal section: %v", err) + } + + var back splitTunnelSection + if err := json.Unmarshal(raw, &back); err != nil { + t.Fatalf("unmarshal section: %v", err) + } + if back.Mode != modeInclude { + t.Errorf("mode = %d, want %d, from %s", back.Mode, modeInclude, raw) + } +} + +func TestSectionFromSettingsRoundTrip(t *testing.T) { + settings := NewSplitTunnelSettings() + settings.Mode = SplitTunnelModeInclude + settings.Included.Add("com.example.a") + settings.Excluded.Add("com.example.b") + + section := sectionFromSettings(settings) + back := settingsFromSection(section) + + if back.Mode != SplitTunnelModeInclude { + t.Errorf("mode = %d, want %d", back.Mode, SplitTunnelModeInclude) + } + if !reflect.DeepEqual(packagesOf(back.Included), []string{"com.example.a"}) { + t.Errorf("included = %v, want [com.example.a]", packagesOf(back.Included)) + } + // The inactive selection survives, so switching mode back does not make the + // user pick their applications again. + if !reflect.DeepEqual(packagesOf(back.Excluded), []string{"com.example.b"}) { + t.Errorf("excluded = %v, want [com.example.b]", packagesOf(back.Excluded)) + } +} + +// A mode the Java side never sets, such as one left by a newer build, must not +// reach the stored section either. +func TestSectionFromSettingsNormalizesAnUnknownMode(t *testing.T) { + section := sectionFromSettings(&SplitTunnelSettings{Mode: 7}) + + if section.Mode != modeOff { + t.Errorf("mode = %d, want %d", section.Mode, modeOff) + } +} + +func TestSectionFromNilSettings(t *testing.T) { + section := sectionFromSettings(nil) + + if section.Mode != modeOff { + t.Errorf("mode = %d, want %d", section.Mode, modeOff) + } + if len(section.Excluded) != 0 || len(section.Included) != 0 { + t.Errorf("selections = %v/%v, want both empty", section.Excluded, section.Included) + } +} + +func TestPackageListIgnoresEmptyAndBounds(t *testing.T) { + list := NewPackageList() + list.Add("com.example.a") + list.Add("") + + if list.Size() != 1 { + t.Errorf("size = %d, want 1", list.Size()) + } + if list.Get(-1) != "" || list.Get(5) != "" { + t.Error("out of range access must return an empty string") + } +} diff --git a/client/android/ssh_client.go b/client/android/ssh_client.go new file mode 100644 index 000000000..2822b6539 --- /dev/null +++ b/client/android/ssh_client.go @@ -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") +} diff --git a/client/android/ssh_known_hosts.go b/client/android/ssh_known_hosts.go new file mode 100644 index 000000000..eea90fd32 --- /dev/null +++ b/client/android/ssh_known_hosts.go @@ -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 +} diff --git a/client/android/ssh_sessions.go b/client/android/ssh_sessions.go new file mode 100644 index 000000000..44b5464e9 --- /dev/null +++ b/client/android/ssh_sessions.go @@ -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}) +} diff --git a/client/anonymize/anonymize.go b/client/anonymize/anonymize.go index c140cef89..c5d43ed55 100644 --- a/client/anonymize/anonymize.go +++ b/client/anonymize/anonymize.go @@ -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 ("-"). 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) diff --git a/client/anonymize/anonymize_test.go b/client/anonymize/anonymize_test.go index 852315fa1..7c3c7bcf8 100644 --- a/client/anonymize/anonymize_test.go +++ b/client/anonymize/anonymize_test.go @@ -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 { diff --git a/client/anonymize/reverse_zone.go b/client/anonymize/reverse_zone.go new file mode 100644 index 000000000..b521b71b7 --- /dev/null +++ b/client/anonymize/reverse_zone.go @@ -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' +} diff --git a/client/anonymize/reverse_zone_test.go b/client/anonymize/reverse_zone_test.go new file mode 100644 index 000000000..8c3b8954a --- /dev/null +++ b/client/anonymize/reverse_zone_test.go @@ -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) + }) + } +} diff --git a/client/cmd/daemon_error.go b/client/cmd/daemon_error.go new file mode 100644 index 000000000..0d5b1307e --- /dev/null +++ b/client/cmd/daemon_error.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "errors" + "fmt" + "strings" + + "google.golang.org/genproto/googleapis/rpc/errdetails" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal/ipcauth" +) + +// daemonCallError prepares a daemon error for display. A refusal the daemon +// raised because the operation needs root/administrator is already guidance +// written for the user, so it is surfaced on its own instead of buried under the +// gRPC envelope and the name of the RPC that hit it. Anything else is wrapped +// with context as usual. +func daemonCallError(context string, err error) error { + if guidance, ok := privilegeGuidance(err); ok { + return errors.New(guidance) + } + return fmt.Errorf("%s: %w", context, err) +} + +// privilegeGuidance renders the daemon's privilege refusal as a summary and the +// command that performs the operation with the privileges it needs. It reports +// false for any other error. +func privilegeGuidance(err error) (string, bool) { + info, ok := privilegeErrorInfo(err) + if !ok { + return "", false + } + + summary := info.GetMetadata()[ipcauth.ErrorMetaSummary] + command := info.GetMetadata()[ipcauth.ErrorMetaCommand] + if summary == "" { + // Detail without a summary: fall back to the status message, which + // carries the same text. + summary = strings.TrimSpace(gstatus.Convert(err).Message()) + } + if command == "" { + return summary, true + } + + return fmt.Sprintf("%s\n\n %s\n", summary, command), true +} + +// privilegeErrorInfo returns the daemon's privilege-refusal detail, if the error +// carries one. +func privilegeErrorInfo(err error) (*errdetails.ErrorInfo, bool) { + if err == nil { + return nil, false + } + + for _, detail := range gstatus.Convert(err).Details() { + info, ok := detail.(*errdetails.ErrorInfo) + if !ok { + continue + } + if info.GetReason() == ipcauth.ErrorReasonPrivilegeRequired && info.GetDomain() == ipcauth.ErrorDomain { + return info, true + } + } + return nil, false +} diff --git a/client/cmd/debug.go b/client/cmd/debug.go index 57e75f663..98fe53626 100644 --- a/client/cmd/debug.go +++ b/client/cmd/debug.go @@ -3,7 +3,6 @@ package cmd import ( "context" "fmt" - "os/user" "strings" "time" @@ -27,10 +26,11 @@ import ( const errCloseConnection = "Failed to close connection: %v" var ( - logFileCount uint32 - systemInfoFlag bool - uploadBundleFlag bool - uploadBundleURLFlag string + logFileCount uint32 + systemInfoFlag bool + uploadBundleFlag bool + uploadBundleURLFlag string + uploadBundleInsecureFlag bool ) var debugCmd = &cobra.Command{ @@ -113,7 +113,7 @@ func debugConfigDump(cmd *cobra.Command, _ []string) error { if err != nil { return fmt.Errorf("get active profile: %v", err) } - currUser, err := user.Current() + currUser, err := profilemanager.InvokingUser() if err != nil { return fmt.Errorf("get current user: %v", err) } @@ -155,6 +155,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 @@ -167,17 +172,19 @@ 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 + request.UploadInsecure = uploadBundleInsecureFlag } resp, err := client.DebugBundle(cmd.Context(), request) if err != nil { - return fmt.Errorf("failed to bundle debug: %v", status.Convert(err).Message()) + return daemonCallError("bundle debug", err) } cmd.Printf("Local file:\n%s\n", resp.GetPath()) @@ -227,6 +234,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 @@ -366,17 +378,19 @@ 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 + request.UploadInsecure = uploadBundleInsecureFlag } resp, err := client.DebugBundle(cmd.Context(), request) if err != nil { - return fmt.Errorf("failed to bundle debug: %v", status.Convert(err).Message()) + return daemonCallError("bundle debug", err) } if needsRestoreUp { @@ -524,10 +538,12 @@ func init() { debugBundleCmd.Flags().BoolVarP(&systemInfoFlag, "system-info", "S", true, "Adds system information to the debug bundle") debugBundleCmd.Flags().BoolVarP(&uploadBundleFlag, "upload-bundle", "U", false, "Uploads the debug bundle to a server") debugBundleCmd.Flags().StringVar(&uploadBundleURLFlag, "upload-bundle-url", types.DefaultBundleURL, "Service URL to get an URL to upload the debug bundle") + debugBundleCmd.Flags().BoolVar(&uploadBundleInsecureFlag, "upload-bundle-insecure", false, "Allow uploading to an http or untrusted-TLS upload server (self-hosted); requires root") forCmd.Flags().Uint32VarP(&logFileCount, "log-file-count", "C", 1, "Number of rotated log files to include in debug bundle") forCmd.Flags().BoolVarP(&systemInfoFlag, "system-info", "S", true, "Adds system information to the debug bundle") forCmd.Flags().BoolVarP(&uploadBundleFlag, "upload-bundle", "U", false, "Uploads the debug bundle to a server") forCmd.Flags().StringVar(&uploadBundleURLFlag, "upload-bundle-url", types.DefaultBundleURL, "Service URL to get an URL to upload the debug bundle") + forCmd.Flags().BoolVar(&uploadBundleInsecureFlag, "upload-bundle-insecure", false, "Allow uploading to an http or untrusted-TLS upload server (self-hosted); requires root") forCmd.Flags().Bool("capture", false, "Capture packets during the debug duration and include in bundle") } diff --git a/client/cmd/jobs.go b/client/cmd/jobs.go new file mode 100644 index 000000000..36aab3570 --- /dev/null +++ b/client/cmd/jobs.go @@ -0,0 +1,13 @@ +package cmd + +// remoteJobsAllowedFlag opts this peer into running remote jobs (e.g. debug +// bundles) requested by the management server. It defaults to false: remote +// jobs are an explicit opt-in, and enabling it is a privileged change (see the +// daemon gate in client/server), mirroring the SSH server opt-in. +const remoteJobsAllowedFlag = "allow-remote-jobs" + +var remoteJobsAllowed bool + +func init() { + upCmd.PersistentFlags().BoolVar(&remoteJobsAllowed, remoteJobsAllowedFlag, false, "Allow the management server to run remote jobs (e.g. debug bundles) on this peer") +} diff --git a/client/cmd/login.go b/client/cmd/login.go index a53cb6d5f..11867be09 100644 --- a/client/cmd/login.go +++ b/client/cmd/login.go @@ -4,8 +4,6 @@ import ( "context" "fmt" "os" - "os/user" - "runtime" "strings" log "github.com/sirupsen/logrus" @@ -17,6 +15,7 @@ import ( "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/auth" "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" nbnet "github.com/netbirdio/netbird/client/net" "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/server" @@ -54,7 +53,7 @@ var loginCmd = &cobra.Command{ // nolint ctx = context.WithValue(ctx, system.DeviceNameCtxKey, hostName) } - username, err := user.Current() + username, err := profilemanager.InvokingUser() if err != nil { return fmt.Errorf("get current user: %v", err) } @@ -75,7 +74,7 @@ var loginCmd = &cobra.Command{ if providedSetupKey != "" { return fmt.Errorf("--extend cannot be combined with a setup key; setup keys can only enrol new peers") } - if err := doExtendSession(ctx, cmd); err != nil { + if err := doExtendSession(ctx, cmd, activeProf); err != nil { return fmt.Errorf("extend session failed: %v", err) } return nil @@ -93,7 +92,7 @@ var loginCmd = &cobra.Command{ return fmt.Errorf("daemon login failed: %v", err) } - cmd.Println("Logging successfully") + cmd.Println("Login successful") return nil }, @@ -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, @@ -177,7 +176,7 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str // (browser + verification URL) and the resulting JWT is forwarded to the // management server's ExtendAuthSession RPC. The tunnel stays up // throughout — no Down/Up, no network-map resync. -func doExtendSession(ctx context.Context, cmd *cobra.Command) error { +func doExtendSession(ctx context.Context, cmd *cobra.Command, activeProf *profilemanager.Profile) error { conn, err := DialClientGRPCServer(ctx, daemonAddr) if err != nil { //nolint @@ -189,15 +188,14 @@ func doExtendSession(ctx context.Context, cmd *cobra.Command) error { client := proto.NewDaemonServiceClient(conn) - req := &proto.RequestExtendAuthSessionRequest{} - // Pre-fill the IdP login hint from the active profile so the user + // 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 resolved profile so the user // doesn't have to retype their email. Best-effort: we still proceed // without a hint if the lookup fails. pm := profilemanager.NewProfileManager() - if active, perr := pm.GetActiveProfile(); perr == nil { - if profState, sperr := pm.GetProfileState(active.ID); sperr == nil && profState.Email != "" { - req.Hint = &profState.Email - } + if profState, perr := pm.GetProfileState(activeProf.ID); perr == nil && profState.Email != "" { + req.Hint = &profState.Email } startResp, err := client.RequestExtendAuthSession(ctx, req) @@ -235,9 +233,11 @@ func getActiveProfile(ctx context.Context, pm *profilemanager.ProfileManager, pr // switch profile if provided if profileName != "" { - if err := switchProfileOnDaemon(ctx, pm, profileName, username); err != nil { + prof, err := switchProfileOnDaemon(ctx, pm, profileName, username) + if err != nil { return nil, fmt.Errorf("switch profile: %v", err) } + return prof, nil } activeProf, err := pm.GetActiveProfile() @@ -251,20 +251,19 @@ func getActiveProfile(ctx context.Context, pm *profilemanager.ProfileManager, pr return activeProf, nil } -func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManager, handle string, username string) error { +func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManager, handle string, username string) (*profilemanager.Profile, error) { resolvedID, err := switchProfile(ctx, handle, username) if err != nil { - return fmt.Errorf("switch profile on daemon: %v", err) + return nil, fmt.Errorf("switch profile on daemon: %v", err) } if err := pm.SwitchProfile(resolvedID); err != nil { - return fmt.Errorf("switch profile: %v", err) + return nil, fmt.Errorf("switch profile: %v", err) } conn, err := DialClientGRPCServer(ctx, daemonAddr) if err != nil { - log.Errorf("failed to connect to service CLI interface %v", err) - return err + return nil, fmt.Errorf("connect to service CLI interface: %w", err) } defer conn.Close() @@ -272,17 +271,17 @@ func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManage status, err := client.Status(ctx, &proto.StatusRequest{}) if err != nil { - return fmt.Errorf("unable to get daemon status: %v", err) + return nil, fmt.Errorf("unable to get daemon status: %v", err) } if status.Status == string(internal.StatusConnected) { if _, err := client.Down(ctx, &proto.DownRequest{}); err != nil { log.Errorf("call service down method: %v", err) - return err + return nil, err } } - return nil + return &profilemanager.Profile{ID: resolvedID}, nil } // switchProfile asks the daemon to switch to the profile identified by @@ -332,6 +331,11 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string, if err != nil { return fmt.Errorf("read config file %s: %v", configFilePath, err) } + // CLI standalone login: profilemanager no longer auto-applies MDM, + // so layer in the OS-native policy here. Desktop builds construct + // a Loader with no fetcher — the build-tagged loadPlatform reads + // the registry/plist directly. + config.ApplyMDMPolicy(mdm.NewLoader(nil).Load()) // Mirror runInForegroundMode: recover residual state (DNS, firewall, // ssh config, legacy routing) from a previous unclean shutdown and @@ -345,7 +349,7 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string, if err != nil { return fmt.Errorf("foreground login failed: %v", err) } - cmd.Println("Logging successfully") + cmd.Println("Login successful") return nil } @@ -408,7 +412,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 +462,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) diff --git a/client/cmd/logout.go b/client/cmd/logout.go index 1a5281acb..cf2a4e446 100644 --- a/client/cmd/logout.go +++ b/client/cmd/logout.go @@ -3,11 +3,11 @@ package cmd import ( "context" "fmt" - "os/user" "time" "github.com/spf13/cobra" + "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/proto" ) @@ -37,7 +37,7 @@ var logoutCmd = &cobra.Command{ if profileName != "" { req.ProfileName = &profileName - currUser, err := user.Current() + currUser, err := profilemanager.InvokingUser() if err != nil { return fmt.Errorf("get current user: %v", err) } @@ -46,7 +46,7 @@ var logoutCmd = &cobra.Command{ } if _, err := daemonClient.Logout(ctx, req); err != nil { - return fmt.Errorf("deregister: %v", err) + return daemonCallError("deregister", err) } cmd.Println("Deregistered successfully") diff --git a/client/cmd/profile.go b/client/cmd/profile.go index 268034e70..2d6653537 100644 --- a/client/cmd/profile.go +++ b/client/cmd/profile.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "os/user" "strings" "text/tabwriter" "time" @@ -97,7 +96,7 @@ func listProfilesFunc(cmd *cobra.Command, _ []string) error { } defer conn.Close() - currUser, err := user.Current() + currUser, err := profilemanager.InvokingUser() if err != nil { return fmt.Errorf("get current user: %w", err) } @@ -138,7 +137,7 @@ func addProfileFunc(cmd *cobra.Command, args []string) error { return err } - currUser, err := user.Current() + currUser, err := profilemanager.InvokingUser() if err != nil { return fmt.Errorf("get current user: %w", err) } @@ -179,7 +178,7 @@ func renameProfileFunc(cmd *cobra.Command, args []string) error { } defer conn.Close() - currUser, err := user.Current() + currUser, err := profilemanager.InvokingUser() if err != nil { return fmt.Errorf("get current user: %w", err) } @@ -233,7 +232,7 @@ func removeProfileFunc(cmd *cobra.Command, args []string) error { } defer conn.Close() - currUser, err := user.Current() + currUser, err := profilemanager.InvokingUser() if err != nil { return fmt.Errorf("get current user: %w", err) } @@ -261,7 +260,7 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error { profileManager := profilemanager.NewProfileManager() handle := args[0] - currUser, err := user.Current() + currUser, err := profilemanager.InvokingUser() if err != nil { return fmt.Errorf("get current user: %w", err) } diff --git a/client/cmd/root.go b/client/cmd/root.go index f1ef32717..be6479440 100644 --- a/client/cmd/root.go +++ b/client/cmd/root.go @@ -20,9 +20,10 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" + "github.com/netbirdio/netbird/client/anonymize" daddr "github.com/netbirdio/netbird/client/internal/daemonaddr" + "github.com/netbirdio/netbird/client/internal/localmetrics" "github.com/netbirdio/netbird/client/internal/profilemanager" ) @@ -31,6 +32,8 @@ const ( dnsResolverAddress = "dns-resolver-address" enableRosenpassFlag = "enable-rosenpass" rosenpassPermissiveFlag = "rosenpass-permissive" + enableLocalMetricsFlag = "enable-local-metrics" + localMetricsAddressFlag = "local-metrics-address" preSharedKeyFlag = "preshared-key" interfaceNameFlag = "interface-name" wireguardPortFlag = "wireguard-port" @@ -70,6 +73,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). @@ -79,6 +83,8 @@ var ( updateSettingsDisabled bool captureEnabled bool networksDisabled bool + localMetricsEnabled bool + localMetricsAddr string rootCmd = &cobra.Command{ Use: "netbird", @@ -91,6 +97,7 @@ var ( // Don't resolve for service commands — they create the socket, not connect to it. if !isServiceCmd(cmd) { daemonAddr = daddr.ResolveUnixDaemonAddr(daemonAddr) + daemonAddr = daddr.ResolveDaemonAddr(daemonAddr) } return nil }, @@ -143,10 +150,10 @@ func init() { defaultDaemonAddr := "unix:///var/run/netbird.sock" if runtime.GOOS == "windows" { - defaultDaemonAddr = "tcp://127.0.0.1:41731" + defaultDaemonAddr = daddr.WindowsPipeAddr } - rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp]://[path|host:port]") + rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp|npipe]://[path|host:port|name]") rootCmd.PersistentFlags().StringVarP(&managementURL, "management-url", "m", "", fmt.Sprintf("Management Service URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultManagementURL)) rootCmd.PersistentFlags().StringVar(&adminURL, "admin-url", "", fmt.Sprintf("Admin Panel URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultAdminURL)) rootCmd.PersistentFlags().StringVarP(&logLevel, "log-level", "l", "info", "sets NetBird log level") @@ -156,7 +163,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) @@ -212,6 +220,8 @@ func init() { upCmd.PersistentFlags().BoolVar(&rosenpassEnabled, enableRosenpassFlag, false, "[Experimental] Enable Rosenpass feature. If enabled, the connection will be post-quantum secured via Rosenpass.") upCmd.PersistentFlags().BoolVar(&rosenpassPermissive, rosenpassPermissiveFlag, false, "[Experimental] Enable Rosenpass in permissive mode to allow this peer to accept WireGuard connections without requiring Rosenpass functionality from peers that do not have Rosenpass enabled.") upCmd.PersistentFlags().BoolVar(&autoConnectDisabled, disableAutoConnectFlag, false, "Disables auto-connect feature. If enabled, then the client won't connect automatically when the service starts.") + upCmd.PersistentFlags().BoolVar(&localMetricsEnabled, enableLocalMetricsFlag, false, "Enables a local Prometheus /metrics endpoint exposing connection state (peers, latency, P2P vs relay).") + upCmd.PersistentFlags().StringVar(&localMetricsAddr, localMetricsAddressFlag, localmetrics.DefaultListenAddress, "Listen address of the local Prometheus /metrics endpoint.") upCmd.PersistentFlags().BoolVar(&lazyConnEnabled, enableLazyConnectionFlag, false, "Deprecated: no longer used. Lazy connections are controlled by the server and the NB_LAZY_CONN environment variable.") _ = upCmd.PersistentFlags().MarkDeprecated(enableLazyConnectionFlag, "no longer used; lazy connections are controlled by the server and the NB_LAZY_CONN environment variable") @@ -269,12 +279,10 @@ func DialClientGRPCServer(ctx context.Context, addr string) (*grpc.ClientConn, e ctx, cancel := context.WithTimeout(ctx, time.Second*10) defer cancel() - return grpc.DialContext( - ctx, - strings.TrimPrefix(addr, "tcp://"), - grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithBlock(), - ) + target, opts := daddr.DialTarget(addr) + opts = append(opts, grpc.WithBlock()) + + return grpc.DialContext(ctx, target, opts...) } // WithBackOff execute function in backoff cycle. @@ -295,6 +303,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) diff --git a/client/cmd/service.go b/client/cmd/service.go index b0a56c71a..7410d60ea 100644 --- a/client/cmd/service.go +++ b/client/cmd/service.go @@ -33,10 +33,15 @@ var ( ) type program struct { - ctx context.Context - cancel context.CancelFunc - serv *grpc.Server - jsonServ *http.Server + ctx context.Context + cancel context.CancelFunc + serv *grpc.Server + jsonServ *http.Server + // jsonClient is the gateway's own connection to the daemon. It is held so + // shutting the gateway down also closes it: nothing else references it once + // the handlers are registered, so its transport goroutines would otherwise + // outlive the server. + jsonClient *grpc.ClientConn jsonServMu sync.Mutex serverInstance *server.Server serverInstanceMu sync.Mutex diff --git a/client/cmd/service_controller.go b/client/cmd/service_controller.go index 5ef13a0a6..e9a0e055f 100644 --- a/client/cmd/service_controller.go +++ b/client/cmd/service_controller.go @@ -5,6 +5,7 @@ package cmd import ( "context" "fmt" + "runtime" "time" "github.com/kardianos/service" @@ -13,6 +14,8 @@ import ( "github.com/spf13/cobra" "google.golang.org/grpc" + "github.com/netbirdio/netbird/client/internal/daemonaddr" + "github.com/netbirdio/netbird/client/internal/ipcauth" "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/server" "github.com/netbirdio/netbird/client/system" @@ -26,6 +29,33 @@ func validateJSONSocketFlags() error { return nil } +// daemonServerOptions installs the transport credentials that expose each +// caller's kernel-authenticated identity to the handlers, which is what lets +// the daemon require root/administrator for privileged operations. +// +// The handshake exchanges no bytes, so older CLI and UI binaries still +// interoperate. Callers on a TCP socket carry no identity at all: the daemon +// keeps serving them, and the privileged operations deny them, so a warning is +// logged to make the loss of functionality visible. +func daemonServerOptions(network string) []grpc.ServerOption { + if network == "tcp" { + log.Warnf("daemon is listening on TCP (%s): callers carry no verifiable identity over TCP, "+ + "so privileged operations (SSH root login, SSH auth, enabling the SSH server, management URL changes, "+ + "deregistration) will be denied, and the SSH JWT cache is neither filled nor served. "+ + "Use a unix socket, or npipe:// on Windows", daemonAddr) + return nil + } + + creds := ipcauth.NewTransportCredentials() //nolint:staticcheck + if creds == nil { //nolint:staticcheck // nil only on platforms without a peer-identity primitive + log.Warnf("daemon IPC has no peer-identity primitive on %s: privileged operations will be denied "+ + "and the SSH JWT cache is neither filled nor served", runtime.GOOS) + return nil + } + + return []grpc.ServerOption{grpc.Creds(creds)} +} + func (p *program) Start(svc service.Service) error { // Start should not block. Do the actual work async. log.Info("starting NetBird service") //nolint @@ -37,68 +67,106 @@ func (p *program) Start(svc service.Service) error { // Collect static system and platform information system.UpdateStaticInfoAsync() - // in any case, even if configuration does not exists we run daemon to serve CLI gRPC API. - p.serv = grpc.NewServer() - - daemonListener, err := listenOnAddress(daemonAddr) - if err != nil { - return fmt.Errorf("listen daemon interface: %w", err) + // A daemon installed before named-pipe support has the loopback TCP address + // persisted. Move it to the named pipe so an upgraded daemon can identify + // its callers instead of silently serving an unauthenticated socket. + if migrated, ok := daemonaddr.MigrateLegacy(daemonAddr); ok { + log.Infof("daemon address %q predates named-pipe support, listening on %q so callers can be identified", daemonAddr, migrated) + daemonAddr = migrated } - var jsonListener *socketListener - if enableJSONSocket { - jsonListener, err = listenOnAddress(jsonSocket) - if err != nil { - _ = daemonListener.Close() - return fmt.Errorf("listen daemon JSON interface: %w", err) - } - } else { - removeStaleUnixSocketForAddress(jsonSocket) + network, _, err := parseListenAddress(daemonAddr) + if err != nil { + return fmt.Errorf("parse daemon address: %w", err) + } + + // in any case, even if configuration does not exists we run daemon to serve CLI gRPC API. + p.serv = grpc.NewServer(daemonServerOptions(network)...) + + daemonListener, jsonListener, err := listenDaemonSockets() + if err != nil { + return err } go func() { - defer daemonListener.Close() - if jsonListener != nil { - defer jsonListener.Close() - } - - if err := daemonListener.chmodUnixSocket("daemon"); err != nil { - log.Error(err) - return - } - if jsonListener != nil { - if err := jsonListener.chmodUnixSocket("daemon JSON"); err != nil { - log.Error(err) - return - } - } - - serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled, captureEnabled, networksDisabled) - if err := serverInstance.Start(); err != nil { - log.Fatalf("failed to start daemon: %v", err) - } - proto.RegisterDaemonServiceServer(p.serv, serverInstance) - - p.serverInstanceMu.Lock() - p.serverInstance = serverInstance - p.serverInstanceMu.Unlock() - - if jsonListener != nil { - if err := p.startJSONGateway(jsonListener, daemonAddr); err != nil { - log.Fatalf("failed to start daemon JSON server: %v", err) - } - } else { - log.Debug("daemon JSON socket disabled") - } - - log.Printf("started daemon server: %v", daemonListener.address) - if err := p.serv.Serve(daemonListener.Listener); err != nil { - log.Errorf("failed to serve daemon requests: %v", err) + // Fatal here rather than inside serve, so serve's deferred listener + // closes run before the process exits. + if err := p.serve(daemonListener, jsonListener); err != nil { + log.Fatalf("failed to %v", err) } }() return nil } +// listenDaemonSockets opens the daemon control socket and, when it is enabled, the +// JSON gateway socket. The control socket is closed again if the second one fails, +// so a failed start leaves nothing listening. The returned JSON listener is nil +// when the socket is disabled. +func listenDaemonSockets() (*socketListener, *socketListener, error) { + daemonListener, err := listenOnAddress(daemonAddr) + if err != nil { + return nil, nil, fmt.Errorf("listen daemon interface: %w", err) + } + + if !enableJSONSocket { + removeStaleUnixSocketForAddress(jsonSocket) + return daemonListener, nil, nil + } + + jsonListener, err := listenOnAddress(jsonSocket) + if err != nil { + if cerr := daemonListener.Close(); cerr != nil { + log.Debugf("close daemon listener: %v", cerr) + } + return nil, nil, fmt.Errorf("listen daemon JSON interface: %w", err) + } + + return daemonListener, jsonListener, nil +} + +// serve brings up the daemon server on an already-open control socket and blocks +// until it stops. jsonListener is nil when the JSON socket is disabled. A returned +// error means the daemon cannot run at all and the caller is expected to exit; the +// failures it recovers from on its own are logged here. +func (p *program) serve(daemonListener, jsonListener *socketListener) error { + defer daemonListener.Close() + if jsonListener != nil { + defer jsonListener.Close() + } + + // chmodUnixSocket is a no-op for a nil listener and for a non-unix one. + if err := daemonListener.chmodUnixSocket("daemon"); err != nil { + log.Error(err) + return nil + } + if err := jsonListener.chmodUnixSocket("daemon JSON"); err != nil { + log.Error(err) + return nil + } + + serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled, captureEnabled, networksDisabled) + if err := serverInstance.Start(); err != nil { + return fmt.Errorf("start daemon: %w", err) + } + proto.RegisterDaemonServiceServer(p.serv, serverInstance) + + p.serverInstanceMu.Lock() + p.serverInstance = serverInstance + p.serverInstanceMu.Unlock() + + if jsonListener == nil { + log.Debug("daemon JSON socket disabled") + } else if err := p.startJSONGateway(jsonListener, daemonAddr); err != nil { + return fmt.Errorf("start daemon JSON server: %w", err) + } + + log.Printf("started daemon server: %v", daemonListener.address) + if err := p.serv.Serve(daemonListener.Listener); err != nil { + log.Errorf("failed to serve daemon requests: %v", err) + } + return nil +} + func (p *program) Stop(srv service.Service) error { p.serverInstanceMu.Lock() if p.serverInstance != nil { @@ -113,8 +181,13 @@ func (p *program) Stop(srv service.Service) error { p.cancel() p.jsonServMu.Lock() - jsonServ := p.jsonServ + jsonServ, jsonClient := p.jsonServ, p.jsonClient p.jsonServMu.Unlock() + if jsonClient != nil { + if err := jsonClient.Close(); err != nil { + log.Debugf("close daemon JSON gateway client: %v", err) + } + } if jsonServ != nil { shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 2*time.Second) if err := jsonServ.Shutdown(shutdownCtx); err != nil { diff --git a/client/cmd/service_json_gateway.go b/client/cmd/service_json_gateway.go index 29c1a6456..b6864f338 100644 --- a/client/cmd/service_json_gateway.go +++ b/client/cmd/service_json_gateway.go @@ -5,27 +5,123 @@ package cmd import ( "context" "errors" + "fmt" "net" "net/http" - "strings" + "sync" "time" "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" log "github.com/sirupsen/logrus" "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" + "github.com/netbirdio/netbird/client/internal/daemonaddr" + "github.com/netbirdio/netbird/client/internal/ipcauth" "github.com/netbirdio/netbird/client/proto" ) -func grpcGatewayEndpoint(addr string) string { - return strings.TrimPrefix(addr, "tcp://") +// jsonPeerIdentity is the context key under which the connecting HTTP client's +// identity is stashed for the lifetime of its connection. +type jsonPeerIdentity struct{} + +// jsonPeerIdentityValue pairs the identity with whether it could be read at +// all, so an unreadable identity is forwarded as "unknown" rather than omitted. +type jsonPeerIdentityValue struct { + id ipcauth.Identity + known bool +} + +// jsonConnContext reads the identity of the client connecting to the JSON +// socket and stashes it on the connection's context. The gateway re-dials the +// daemon in-process, so the daemon would otherwise see every JSON request as +// coming from the daemon itself. +func jsonConnContext(ctx context.Context, c net.Conn) context.Context { + value := jsonPeerIdentityValue{} + id, err := ipcauth.ConnIdentity(c) + if err != nil { + log.Warnf("json gateway: cannot read HTTP client identity, privileged operations will be denied for this connection: %v", err) + } else { + value.id = id + value.known = true + } + return context.WithValue(ctx, jsonPeerIdentity{}, value) +} + +// forwardIdentity stamps the HTTP client's identity onto every call the gateway +// makes to the daemon. +// +// It is an interceptor on the gateway's client connection rather than a +// runtime.WithMetadata annotator because grpc-gateway skips annotators when no +// request header maps to metadata, which an HTTP/1.0 request with no Host header +// over a unix socket achieves. The daemon would then receive no marker, see its own +// identity as the transport peer, and authorize the request as the daemon itself. +// An interceptor runs for every RPC whatever the request looked like. +func forwardIdentity(ctx context.Context) context.Context { + value, ok := ctx.Value(jsonPeerIdentity{}).(jsonPeerIdentityValue) + if !ok { + // No ConnContext ran for this request, so forward an unknown identity: + // the daemon must not mistake its own identity for the client's. + return ipcauth.WithForwardedIdentity(ctx, ipcauth.Identity{}, false) + } + return ipcauth.WithForwardedIdentity(ctx, value.id, value.known) +} + +func forwardIdentityUnary(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + return invoker(forwardIdentity(ctx), method, req, reply, cc, opts...) +} + +func forwardIdentityStream(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) { + return streamer(forwardIdentity(ctx), desc, cc, method, opts...) +} + +// reservedHeaderWarning limits the dropped-header warning to the first occurrence. +var reservedHeaderWarning sync.Once + +// jsonIncomingHeaderMatcher keeps an HTTP client from supplying the metadata the +// gateway uses to forward its identity. grpc-gateway turns "Grpc-Metadata-" +// headers into gRPC metadata and joins them ahead of what its annotators add, so +// without this filter a JSON client could send its own x-netbird-fwd-uid and the +// daemon would authorize that instead of the client's real identity. +func jsonIncomingHeaderMatcher(key string) (string, bool) { + mapped, ok := runtime.DefaultHeaderMatcher(key) + if !ok { + return "", false + } + if ipcauth.IsReservedForwardKey(mapped) { + // Warn once: any client can send these on every request, so warning each + // time hands it a way to fill the log. The rest are debug-level. + reservedHeaderWarning.Do(func() { + log.Warnf("json gateway: dropping reserved header %q from a request: only the gateway may set the caller's identity", key) + }) + log.Debugf("json gateway: dropping reserved header %q", key) + return "", false + } + return mapped, true } func (p *program) startJSONGateway(jsonListener *socketListener, daemonEndpoint string) error { - mux := runtime.NewServeMux() - opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())} - if err := proto.RegisterDaemonServiceHandlerFromEndpoint(p.ctx, mux, grpcGatewayEndpoint(daemonEndpoint), opts); err != nil { + if jsonListener.network == "tcp" { + log.Warnf("daemon JSON socket is listening on TCP (%s): callers carry no verifiable identity over TCP, "+ + "so privileged operations will be denied for JSON clients", jsonListener.address) + } + + mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher)) + + // grpc.NewClient does not connect until the first request, so registering + // the handler here cannot block daemon startup. + target, opts := daemonaddr.DialTarget(daemonEndpoint) + opts = append(opts, + grpc.WithChainUnaryInterceptor(forwardIdentityUnary), + grpc.WithChainStreamInterceptor(forwardIdentityStream), + ) + conn, err := grpc.NewClient(target, opts...) + if err != nil { + return fmt.Errorf("create daemon client for JSON gateway: %w", err) + } + if err := proto.RegisterDaemonServiceHandler(p.ctx, mux, conn); err != nil { + if cerr := conn.Close(); cerr != nil { + log.Debugf("close daemon client after failed JSON gateway registration: %v", cerr) + } return err } @@ -35,10 +131,12 @@ func (p *program) startJSONGateway(jsonListener *socketListener, daemonEndpoint BaseContext: func(net.Listener) context.Context { return p.ctx }, + ConnContext: jsonConnContext, } p.jsonServMu.Lock() p.jsonServ = jsonServer + p.jsonClient = conn p.jsonServMu.Unlock() go func() { diff --git a/client/cmd/service_json_gateway_test.go b/client/cmd/service_json_gateway_test.go new file mode 100644 index 000000000..dfeef1c46 --- /dev/null +++ b/client/cmd/service_json_gateway_test.go @@ -0,0 +1,261 @@ +//go:build !windows && !ios && !android + +package cmd + +import ( + "context" + "net" + "net/http" + "path/filepath" + "testing" + "time" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/peer" + + "github.com/netbirdio/netbird/client/internal/ipcauth" +) + +// The JSON gateway runs inside the daemon and re-dials it locally, so every JSON +// request reaches a handler with the daemon's own identity as the transport peer. +// The gateway therefore forwards its HTTP client's identity as metadata, and the +// daemon authorizes that instead of itself. These tests drive the real wiring +// (jsonConnContext, forwardIdentity, jsonIncomingHeaderMatcher) and check the +// identity a handler would end up authorizing. + +// daemonSideCtx is what a handler sees for a gateway-relayed call. The transport +// peer must be this process's own identity: the gateway is the daemon, so the two +// cannot differ, and hardcoding root here instead would describe a state that +// never occurs. +func daemonSideCtx(t *testing.T, md metadata.MD) context.Context { + t.Helper() + self, err := ipcauth.CurrentProcessIdentity() + if err != nil { + t.Skipf("cannot read this process's identity: %v", err) + } + ctx := peer.NewContext(context.Background(), &peer.Peer{ + AuthInfo: ipcauth.AuthInfo{ + CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity}, + Identity: self, + }, + }) + return metadata.NewIncomingContext(ctx, md) +} + +// gatewayMetadata reproduces what the daemon receives for a JSON request: the +// mux annotates the context from the request's headers, then the interceptor on the +// gateway's client connection stamps the caller's identity. The order matters, +// since the interceptor must win over anything a header put there. +func gatewayMetadata(t *testing.T, req *http.Request, ctx context.Context) metadata.MD { + t.Helper() + mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher)) + annotated, err := runtime.AnnotateContext(ctx, mux, req, + "/daemon.DaemonService/SetConfig", + runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig")) + if err != nil { + t.Fatalf("annotate: %v", err) + } + + md, ok := metadata.FromOutgoingContext(forwardIdentity(annotated)) + if !ok { + t.Fatal("the interceptor produced no metadata") + } + return md +} + +// clientCtx is the connection context jsonConnContext would have produced for an +// HTTP client whose identity the gateway could read. +func clientCtx(id ipcauth.Identity, known bool) context.Context { + return context.WithValue(context.Background(), jsonPeerIdentity{}, + jsonPeerIdentityValue{id: id, known: known}) +} + +// An HTTP client must not be able to name its own identity. grpc-gateway turns +// Grpc-Metadata- headers into gRPC metadata, so without the header filter and +// the interceptor overwriting the reserved keys, this request would authorize as +// uid 0. +func TestJSONGateway_ForgedIdentityHeaderIsDropped(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Grpc-Metadata-X-Netbird-Fwd-Uid", "0") + req.Header.Set("Grpc-Metadata-X-Netbird-Fwd-Gid", "0") + req.Header.Set("Grpc-Metadata-X-Netbird-Fwd", "1") + req.Header.Set("Grpc-Metadata-X-Netbird-Fwd-Sid", "S-1-5-18") + + caller := ipcauth.Identity{UID: 31000, GID: 31000} + md := gatewayMetadata(t, req, clientCtx(caller, true)) + + id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md)) + if !ok { + t.Fatal("the forwarded identity should be usable") + } + if id.IsPrivileged() { + t.Errorf("forged header was believed: authorized as %v", id) + } + if id.UID != caller.UID { + t.Errorf("authorized as uid %d, want the real client %d", id.UID, caller.UID) + } +} + +// A request with no headers at all (HTTP/1.0 needs no Host, and a unix socket +// yields no host:port) makes grpc-gateway produce no metadata whatsoever and skip +// its annotators: "if len(pairs) == 0 { return ctx, nil, nil }" in +// runtime/context.go. That is why the identity is stamped by an interceptor +// instead. This is the case that previously reached the gate as the daemon itself. +func TestJSONGateway_HeaderlessRequestIsStillMarkedForwarded(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil) + if err != nil { + t.Fatal(err) + } + req.Header = http.Header{} + req.Host = "" + + caller := ipcauth.Identity{UID: 31000, GID: 31000} + ctx := clientCtx(caller, true) + + // Pin the skip path itself: if grpc-gateway ever produced a pair here, this + // test would still pass below while no longer covering what it was written for. + mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher)) + annotated, err := runtime.AnnotateContext(ctx, mux, req, + "/daemon.DaemonService/SetConfig", + runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig")) + if err != nil { + t.Fatalf("annotate: %v", err) + } + if md, ok := metadata.FromOutgoingContext(annotated); ok { + t.Fatalf("grpc-gateway produced metadata %v for a headerless request; "+ + "this test no longer covers the annotator-skip path", md) + } + + md := gatewayMetadata(t, req, ctx) + + id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md)) + if !ok { + t.Fatal("the forwarded identity should be usable") + } + if id.UID != caller.UID || id.IsPrivileged() { + t.Errorf("authorized as %v, want the real client uid %d", id, caller.UID) + } +} + +// When the gateway cannot read its client's identity (a TCP JSON socket, say) it +// forwards the marker alone. The daemon must then report "unidentified" so the +// privileged operations refuse, rather than falling back to the gateway's own +// identity. +func TestJSONGateway_UnreadableClientIdentityIsUnidentified(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil) + if err != nil { + t.Fatal(err) + } + + md := gatewayMetadata(t, req, clientCtx(ipcauth.Identity{}, false)) + + if id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md)); ok { + t.Errorf("a request with no client identity was authorized as %v", id) + } +} + +// A request that never passed through jsonConnContext (no stashed identity) must +// also come out unidentified rather than as the daemon. +func TestJSONGateway_MissingConnContextIsUnidentified(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil) + if err != nil { + t.Fatal(err) + } + + md := gatewayMetadata(t, req, context.Background()) + + if id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md)); ok { + t.Errorf("a request with no connection context was authorized as %v", id) + } +} + +// End to end over a real unix socket: the gateway reads the connecting client's +// identity from the socket itself, so a client cannot present anything else. +func TestJSONGateway_IdentityComesFromTheSocket(t *testing.T) { + mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher)) + + type observed struct { + md metadata.MD + } + seen := make(chan observed, 1) + + srv := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx, err := runtime.AnnotateContext(r.Context(), mux, r, + "/daemon.DaemonService/SetConfig", + runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig")) + if err != nil { + t.Errorf("annotate: %v", err) + return + } + md, _ := metadata.FromOutgoingContext(forwardIdentity(ctx)) + seen <- observed{md: md} + w.WriteHeader(http.StatusOK) + }), + ReadHeaderTimeout: 5 * time.Second, + ConnContext: jsonConnContext, + } + + sock := filepath.Join(t.TempDir(), "http.sock") + ln, err := net.Listen("unix", sock) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := srv.Close(); err != nil { + t.Logf("close server: %v", err) + } + }) + go func() { + if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { + t.Logf("serve: %v", err) + } + }() + + conn, err := net.Dial("unix", sock) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := conn.Close(); err != nil { + t.Logf("close conn: %v", err) + } + }) + + // Forge the identity headers on the wire as well. + request := "POST /daemon.DaemonService/SetConfig HTTP/1.1\r\n" + + "Host: localhost\r\n" + + "Grpc-Metadata-X-Netbird-Fwd: 1\r\n" + + "Grpc-Metadata-X-Netbird-Fwd-Uid: 0\r\n" + + "Content-Length: 0\r\n\r\n" + if _, err := conn.Write([]byte(request)); err != nil { + t.Fatal(err) + } + + select { + case got := <-seen: + self, err := ipcauth.CurrentProcessIdentity() + if err != nil { + t.Skipf("cannot read this process's identity: %v", err) + } + // The socket peer is this test process, so that is the identity the + // gateway must forward, not the uid 0 the request asked for. + if uids := got.md.Get("x-netbird-fwd-uid"); len(uids) != 1 { + t.Fatalf("x-netbird-fwd-uid = %v, want exactly the gateway's own value", uids) + } + id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, got.md)) + if !ok { + t.Fatal("the forwarded identity should be usable") + } + if id.UID != self.UID { + t.Errorf("authorized as uid %d, want the socket peer %d", id.UID, self.UID) + } + case <-time.After(5 * time.Second): + t.Fatal("the gateway never handled the request") + } +} diff --git a/client/cmd/service_params.go b/client/cmd/service_params.go index f25087a69..750b22ae6 100644 --- a/client/cmd/service_params.go +++ b/client/cmd/service_params.go @@ -13,6 +13,7 @@ import ( "github.com/spf13/cobra" "github.com/netbirdio/netbird/client/configs" + "github.com/netbirdio/netbird/client/internal/daemonaddr" "github.com/netbirdio/netbird/util" ) @@ -125,6 +126,13 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) { if !rootCmd.PersistentFlags().Changed("daemon-addr") && params.DaemonAddr != "" { daemonAddr = params.DaemonAddr + // An install that predates named-pipe support has the loopback TCP + // address saved. Callers carry no identity over TCP, so move it to the + // pipe instead of restoring a socket the daemon cannot authorize on. + if migrated, ok := daemonaddr.MigrateLegacy(daemonAddr); ok { + cmd.Printf("Moving the saved daemon address from %s to %s so the daemon can identify its callers\n", daemonAddr, migrated) + daemonAddr = migrated + } } if !serviceCmd.PersistentFlags().Changed("json-socket") && params.JSONSocket != "" { diff --git a/client/cmd/service_pipe_other.go b/client/cmd/service_pipe_other.go new file mode 100644 index 000000000..c7cc72469 --- /dev/null +++ b/client/cmd/service_pipe_other.go @@ -0,0 +1,14 @@ +//go:build !windows + +package cmd + +import ( + "fmt" + "net" +) + +// listenNamedPipe is Windows-only: no other platform serves the daemon on a +// named pipe. +func listenNamedPipe(string) (net.Listener, string, error) { + return nil, "", fmt.Errorf("named pipes are only supported on Windows") +} diff --git a/client/cmd/service_pipe_windows.go b/client/cmd/service_pipe_windows.go new file mode 100644 index 000000000..b6e860f51 --- /dev/null +++ b/client/cmd/service_pipe_windows.go @@ -0,0 +1,41 @@ +//go:build windows + +package cmd + +import ( + "errors" + "fmt" + "net" + + "github.com/Microsoft/go-winio" + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/daemonaddr" + "github.com/netbirdio/netbird/client/internal/ipcauth" +) + +// listenNamedPipe creates the daemon control pipe and reports the path it ended +// up on. The security descriptor lets any local caller connect, as a Unix socket +// at 0666 does, and the privileged operations are authorized separately from the +// caller's token. +// +// The protected name comes first so that an unprivileged process cannot take the +// name before the service does. Creating it requires being an administrator or +// LocalSystem, so a daemon an ordinary user runs themselves, as in netstack mode, +// falls back to the plain name; clients try both and check who serves them. +func listenNamedPipe(name string) (net.Listener, string, error) { + var errs []error + for _, path := range daemonaddr.PipePaths(name) { + listener, err := winio.ListenPipe(path, &winio.PipeConfig{ + SecurityDescriptor: ipcauth.DefaultPipeSDDL(), + }) + if err != nil { + log.Debugf("not serving the daemon on %s: %v", path, err) + errs = append(errs, fmt.Errorf("%s: %w", path, err)) + continue + } + return listener, path, nil + } + + return nil, "", errors.Join(errs...) +} diff --git a/client/cmd/service_socket.go b/client/cmd/service_socket.go index f825a4062..bf3122f7c 100644 --- a/client/cmd/service_socket.go +++ b/client/cmd/service_socket.go @@ -26,6 +26,14 @@ func listenOnAddress(addr string) (*socketListener, error) { return nil, err } + if network == "npipe" { + listener, path, err := listenNamedPipe(address) //nolint:staticcheck + if err != nil { //nolint:staticcheck // always errors on non-Windows builds + return nil, err + } + return &socketListener{Listener: listener, network: network, address: path}, nil + } + if network == "unix" { removeStaleUnixSocket(address) } @@ -41,11 +49,11 @@ func listenOnAddress(addr string) (*socketListener, error) { func parseListenAddress(addr string) (string, string, error) { network, address, ok := strings.Cut(addr, "://") if !ok || network == "" || address == "" { - return "", "", fmt.Errorf("address must be in [unix|tcp]://[path|host:port] format: %q", addr) + return "", "", fmt.Errorf("address must be in [unix|tcp|npipe]://[path|host:port|name] format: %q", addr) } switch network { - case "unix", "tcp": + case "unix", "tcp", "npipe": return network, address, nil default: return "", "", fmt.Errorf("unsupported daemon address protocol: %v", network) diff --git a/client/cmd/status.go b/client/cmd/status.go index c4057ed82..f2e5bcc66 100644 --- a/client/cmd/status.go +++ b/client/cmd/status.go @@ -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, diff --git a/client/cmd/testutil_test.go b/client/cmd/testutil_test.go index 205327ef5..328a15454 100644 --- a/client/cmd/testutil_test.go +++ b/client/cmd/testutil_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" "google.golang.org/grpc" @@ -124,7 +124,7 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := mgmt.NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersmanager), config) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersmanager), config, nil) accountManager, err := mgmt.BuildManager(ctx, config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore) if err != nil { diff --git a/client/cmd/up.go b/client/cmd/up.go index 2d9731f26..f5fac9749 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -2,10 +2,10 @@ package cmd import ( "context" + "errors" "fmt" "net" "net/netip" - "os/user" "runtime" "strings" "time" @@ -21,8 +21,9 @@ 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" + "github.com/netbirdio/netbird/client/mdm" 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" @@ -48,6 +49,8 @@ const ( profileNameDesc = "profile name to use for the login. If not specified, the last used profile will be used." ) +var errDaemonActiveProfileUnsupported = errors.New("daemon does not support active profile lookup") + var ( foregroundMode bool dnsLabels []string @@ -122,23 +125,25 @@ func upFunc(cmd *cobra.Command, args []string) error { pm := profilemanager.NewProfileManager() - username, err := user.Current() + username, err := profilemanager.InvokingUser() if err != nil { return fmt.Errorf("get current user: %v", err) } + var activeProf *profilemanager.Profile var profileSwitched bool // switch profile if provided if profileName != "" { - if err := switchOrCreateProfile(cmd.Context(), pm, profileName, username.Username); err != nil { + activeProf, err = switchOrCreateProfile(cmd.Context(), pm, profileName, username.Username) + if err != nil { return fmt.Errorf("switch profile: %v", err) } profileSwitched = true - } - - activeProf, err := pm.GetActiveProfile() - if err != nil { - return fmt.Errorf("get active profile: %v", err) + } else { + activeProf, err = pm.GetActiveProfile() + if err != nil { + return fmt.Errorf("get active profile: %v", err) + } } if foregroundMode { @@ -150,13 +155,15 @@ func upFunc(cmd *cobra.Command, args []string) error { // switchOrCreateProfile switches the active profile to the one identified by // handle, creating it first when it does not exist yet. This restores the // pre-0.73 behaviour where `netbird up --profile ` auto-creates a -// missing profile instead of failing. -func switchOrCreateProfile(ctx context.Context, pm *profilemanager.ProfileManager, handle, username string) error { +// missing profile instead of failing. Returns the daemon-resolved profile so +// callers act on it directly instead of re-reading the local state, which is +// not updated under sudo. +func switchOrCreateProfile(ctx context.Context, pm *profilemanager.ProfileManager, handle, username string) (*profilemanager.Profile, error) { resolvedID, err := switchProfile(ctx, handle, username) if err != nil { st, ok := gstatus.FromError(err) if !ok || st.Code() != codes.NotFound { - return err + return nil, err } // Don't fail immediately on a create error: a concurrent run may // have created the profile between the NotFound above and this @@ -165,16 +172,16 @@ func switchOrCreateProfile(ctx context.Context, pm *profilemanager.ProfileManage _, createErr := createProfile(ctx, handle, username) if resolvedID, err = switchProfile(ctx, handle, username); err != nil { if createErr != nil { - return fmt.Errorf("create profile: %w", createErr) + return nil, fmt.Errorf("create profile: %w", createErr) } - return err + return nil, err } } if err := pm.SwitchProfile(resolvedID); err != nil { - return err + return nil, err } - return nil + return &profilemanager.Profile{ID: resolvedID}, nil } // createProfile dials the daemon and creates a new profile with the given @@ -228,6 +235,10 @@ func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *pr if err != nil { return fmt.Errorf("get config file: %v", err) } + // CLI foreground path runs without the daemon Server: layer in the + // active MDM policy explicitly so a forced ManagementURL / PSK / + // other managed key actually takes effect on this run. + config.ApplyMDMPolicy(mdm.NewLoader(nil).Load()) _, _ = profilemanager.UpdateOldManagementURL(ctx, config, configFilePath) @@ -302,6 +313,30 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager return fmt.Errorf("unable to get daemon status: %v", err) } + // Under sudo the invoking user's local active-profile mirror is never + // written (the SwitchProfile write is a no-op), and plain root has no + // invoking user at all — so the mirror read into activeProf above is stale + // or defaulted and must not drive the daemon. With no --profile to make the + // choice explicit, take the profile the daemon already holds for this user + // instead: it stays on the user's current profile rather than silently + // switching to the mirror's default, and refuses when the daemon is on + // another user's profile. + if profileName == "" && !profilemanager.MirrorIsAuthoritative() { + u, err := profilemanager.InvokingUser() + if err != nil { + return fmt.Errorf("get current user: %v", err) + } + resolved, err := daemonActiveProfileForUser(ctx, client, u.Username) + switch { + case errors.Is(err, errDaemonActiveProfileUnsupported): + log.Warnf("keeping the locally resolved profile: %v", err) + case err != nil: + return err + default: + activeProf = resolved + } + } + if status.Status == string(internal.StatusConnected) { if !profileSwitched { cmd.Println("Already connected") @@ -314,7 +349,7 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager } } - username, err := user.Current() + username, err := profilemanager.InvokingUser() if err != nil { return fmt.Errorf("get current user: %v", err) } @@ -325,7 +360,7 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Unavailable { log.Warnf("setConfig method is not available in the daemon: %s", st.Message()) } else { - return fmt.Errorf("call service setConfig method: %v", err) + return daemonCallError("call service setConfig method", err) } } @@ -379,7 +414,7 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ } if loginErr != nil { - return fmt.Errorf("login failed: %v", loginErr) + return daemonCallError("login failed", loginErr) } if loginResp.NeedsSSOLogin { @@ -392,32 +427,27 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ ProfileName: &profileID, Username: &username, }); err != nil { - return fmt.Errorf("call service up method: %v", err) + return daemonCallError("call service up method", err) } return nil } -func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, profileName, username string) *proto.SetConfigRequest { - var req proto.SetConfigRequest - req.ProfileName = profileName - req.Username = username - - req.ManagementUrl = managementURL - req.AdminURL = adminURL - req.NatExternalIPs = natExternalIPs - req.CustomDNSAddress = customDNSAddressConverted - req.ExtraIFaceBlacklist = extraIFaceBlackList - req.DnsLabels = dnsLabelsValidated.ToPunycodeList() - req.CleanDNSLabels = dnsLabels != nil && len(dnsLabels) == 0 - req.CleanNATExternalIPs = natExternalIPs != nil && len(natExternalIPs) == 0 - - if cmd.Flag(enableRosenpassFlag).Changed { - req.RosenpassEnabled = &rosenpassEnabled - } - if cmd.Flag(rosenpassPermissiveFlag).Changed { - req.RosenpassPermissive = &rosenpassPermissive +// setBoolPtrIfChanged points dst at a copy of val when the named bool flag was +// explicitly set on cmd. It collapses the repeated +// "if cmd.Flag(x).Changed { field = &val }" pattern in the request builders into +// a single call, keeping their cognitive complexity within bounds. +func setBoolPtrIfChanged(cmd *cobra.Command, name string, dst **bool, val bool) { + if cmd.Flag(name).Changed { + dst2 := val + *dst = &dst2 } +} + +// setSSHSetConfigFields copies the SSH server flags the user actually +// passed into req, leaving the rest unset so the daemon keeps the +// persisted values. +func setSSHSetConfigFields(req *proto.SetConfigRequest, cmd *cobra.Command) { if cmd.Flag(serverSSHAllowedFlag).Changed { req.ServerSSHAllowed = &serverSSHAllowed } @@ -440,6 +470,31 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro sshJWTCacheTTL32 := int32(sshJWTCacheTTL) req.SshJWTCacheTTL = &sshJWTCacheTTL32 } +} + +func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, profileName, username string) *proto.SetConfigRequest { + var req proto.SetConfigRequest + req.ProfileName = profileName + req.Username = username + + req.ManagementUrl = managementURL + req.AdminURL = adminURL + req.NatExternalIPs = natExternalIPs + req.CustomDNSAddress = customDNSAddressConverted + req.ExtraIFaceBlacklist = extraIFaceBlackList + req.DnsLabels = dnsLabelsValidated.ToPunycodeList() + req.CleanDNSLabels = dnsLabels != nil && len(dnsLabels) == 0 + req.CleanNATExternalIPs = natExternalIPs != nil && len(natExternalIPs) == 0 + + if cmd.Flag(enableRosenpassFlag).Changed { + req.RosenpassEnabled = &rosenpassEnabled + } + if cmd.Flag(rosenpassPermissiveFlag).Changed { + req.RosenpassPermissive = &rosenpassPermissive + } + setSSHSetConfigFields(&req, cmd) + setBoolPtrIfChanged(cmd, remoteJobsAllowedFlag, &req.RemoteJobsAllowed, remoteJobsAllowed) + if cmd.Flag(interfaceNameFlag).Changed { if err := parseInterfaceName(interfaceName); err != nil { log.Errorf("parse interface name: %v", err) @@ -499,6 +554,13 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro req.DisableIpv6 = &disableIPv6 } + if cmd.Flag(enableLocalMetricsFlag).Changed { + req.EnableLocalMetrics = &localMetricsEnabled + } + if cmd.Flag(localMetricsAddressFlag).Changed { + req.LocalMetricsAddress = &localMetricsAddr + } + return &req } @@ -523,6 +585,7 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil if cmd.Flag(serverSSHAllowedFlag).Changed { ic.ServerSSHAllowed = &serverSSHAllowed } + setBoolPtrIfChanged(cmd, remoteJobsAllowedFlag, &ic.RemoteJobsAllowed, remoteJobsAllowed) if cmd.Flag(enableSSHRootFlag).Changed { ic.EnableSSHRoot = &enableSSHRoot @@ -616,9 +679,45 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil ic.DisableIPv6 = &disableIPv6 } + if cmd.Flag(enableLocalMetricsFlag).Changed { + ic.LocalMetricsEnabled = &localMetricsEnabled + } + + if cmd.Flag(localMetricsAddressFlag).Changed { + ic.LocalMetricsAddress = &localMetricsAddr + } + return &ic, nil } +// setSSHLoginFields copies the SSH server flags the user actually passed +// into req, leaving the rest unset so the daemon keeps the persisted +// values. +func setSSHLoginFields(req *proto.LoginRequest, cmd *cobra.Command) { + if cmd.Flag(serverSSHAllowedFlag).Changed { + req.ServerSSHAllowed = &serverSSHAllowed + } + if cmd.Flag(enableSSHRootFlag).Changed { + req.EnableSSHRoot = &enableSSHRoot + } + if cmd.Flag(enableSSHSFTPFlag).Changed { + req.EnableSSHSFTP = &enableSSHSFTP + } + if cmd.Flag(enableSSHLocalPortForwardFlag).Changed { + req.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward + } + if cmd.Flag(enableSSHRemotePortForwardFlag).Changed { + req.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward + } + if cmd.Flag(disableSSHAuthFlag).Changed { + req.DisableSSHAuth = &disableSSHAuth + } + if cmd.Flag(sshJWTCacheTTLFlag).Changed { + sshJWTCacheTTL32 := int32(sshJWTCacheTTL) + req.SshJWTCacheTTL = &sshJWTCacheTTL32 + } +} + func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte, cmd *cobra.Command) (*proto.LoginRequest, error) { loginRequest := proto.LoginRequest{ SetupKey: providedSetupKey, @@ -626,7 +725,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, @@ -645,39 +744,21 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte loginRequest.RosenpassPermissive = &rosenpassPermissive } - if cmd.Flag(serverSSHAllowedFlag).Changed { - loginRequest.ServerSSHAllowed = &serverSSHAllowed - } - - if cmd.Flag(enableSSHRootFlag).Changed { - loginRequest.EnableSSHRoot = &enableSSHRoot - } - - if cmd.Flag(enableSSHSFTPFlag).Changed { - loginRequest.EnableSSHSFTP = &enableSSHSFTP - } - - if cmd.Flag(enableSSHLocalPortForwardFlag).Changed { - loginRequest.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward - } - - if cmd.Flag(enableSSHRemotePortForwardFlag).Changed { - loginRequest.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward - } - - if cmd.Flag(disableSSHAuthFlag).Changed { - loginRequest.DisableSSHAuth = &disableSSHAuth - } - - if cmd.Flag(sshJWTCacheTTLFlag).Changed { - sshJWTCacheTTL32 := int32(sshJWTCacheTTL) - loginRequest.SshJWTCacheTTL = &sshJWTCacheTTL32 - } + setSSHLoginFields(&loginRequest, cmd) + setBoolPtrIfChanged(cmd, remoteJobsAllowedFlag, &loginRequest.RemoteJobsAllowed, remoteJobsAllowed) if cmd.Flag(disableAutoConnectFlag).Changed { loginRequest.DisableAutoConnect = &autoConnectDisabled } + if cmd.Flag(enableLocalMetricsFlag).Changed { + loginRequest.EnableLocalMetrics = &localMetricsEnabled + } + + if cmd.Flag(localMetricsAddressFlag).Changed { + loginRequest.LocalMetricsAddress = &localMetricsAddr + } + if cmd.Flag(interfaceNameFlag).Changed { if err := parseInterfaceName(interfaceName); err != nil { return nil, err @@ -849,3 +930,31 @@ func isValidAddrPort(input string) bool { _, err := netip.ParseAddrPort(input) return err == nil } + +// daemonActiveProfileForUser returns the profile the daemon currently holds for +// username, for the no --profile case where the local mirror is not +// authoritative (sudo or plain root). It returns that profile when the daemon +// owns it for this user or when the profile is unowned (empty username, as on a +// fresh install), so the caller acts on the daemon's real state instead of the +// stale mirror. It denies with a --profile hint when the daemon is on another +// user's profile, when the lookup fails, or when the daemon reports no active +// profile. Returns errDaemonActiveProfileUnsupported when the daemon predates +// the RPC; the caller keeps the mirror-derived profile in that case. +func daemonActiveProfileForUser(ctx context.Context, client proto.DaemonServiceClient, username string) (*profilemanager.Profile, error) { + active, err := client.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{}) + if err != nil { + if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Unimplemented { + return nil, fmt.Errorf("%w: %v", errDaemonActiveProfileUnsupported, err) + } + return nil, fmt.Errorf("pass --profile to choose the profile explicitly: the daemon's active profile could not be verified: %v", err) + } + if active.GetId() == "" { + return nil, fmt.Errorf("pass --profile to choose the profile explicitly: the daemon reported no active profile") + } + if active.GetUsername() != "" && active.GetUsername() != username { + return nil, fmt.Errorf( + "pass --profile to choose the profile explicitly: the daemon's active profile is %q (user %q) but this invocation runs for %q", + active.GetProfileName(), active.GetUsername(), username) + } + return &profilemanager.Profile{ID: profilemanager.ID(active.GetId())}, nil +} diff --git a/client/cmd/up_test.go b/client/cmd/up_test.go new file mode 100644 index 000000000..9b5f9fbea --- /dev/null +++ b/client/cmd/up_test.go @@ -0,0 +1,88 @@ +package cmd + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/proto" +) + +type fakeActiveProfileClient struct { + proto.DaemonServiceClient + resp *proto.GetActiveProfileResponse + err error +} + +func (f *fakeActiveProfileClient) GetActiveProfile(_ context.Context, _ *proto.GetActiveProfileRequest, _ ...grpc.CallOption) (*proto.GetActiveProfileResponse, error) { + return f.resp, f.err +} + +func TestDaemonActiveProfileForUserReturnsOwnProfile(t *testing.T) { + client := &fakeActiveProfileClient{resp: &proto.GetActiveProfileResponse{Id: "default", ProfileName: "default", Username: "root"}} + prof, err := daemonActiveProfileForUser(context.Background(), client, "root") + require.NoError(t, err) + require.NotNil(t, prof) + assert.Equal(t, profilemanager.ID("default"), prof.ID) +} + +func TestDaemonActiveProfileForUserReturnsUnownedProfile(t *testing.T) { + client := &fakeActiveProfileClient{resp: &proto.GetActiveProfileResponse{Id: "default", ProfileName: "default", Username: ""}} + prof, err := daemonActiveProfileForUser(context.Background(), client, "root") + require.NoError(t, err) + require.NotNil(t, prof) + assert.Equal(t, profilemanager.ID("default"), prof.ID) +} + +func TestDaemonActiveProfileForUserKeepsDaemonProfileOverStaleMirror(t *testing.T) { + client := &fakeActiveProfileClient{resp: &proto.GetActiveProfileResponse{Id: "ab12", ProfileName: "work", Username: "misha"}} + prof, err := daemonActiveProfileForUser(context.Background(), client, "misha") + require.NoError(t, err) + require.NotNil(t, prof) + assert.Equal(t, profilemanager.ID("ab12"), prof.ID) +} + +func TestDaemonActiveProfileForUserRejectsOtherUsersProfile(t *testing.T) { + client := &fakeActiveProfileClient{resp: &proto.GetActiveProfileResponse{Id: "ab12", ProfileName: "work", Username: "misha"}} + prof, err := daemonActiveProfileForUser(context.Background(), client, "root") + require.Error(t, err) + assert.Nil(t, prof) + assert.Contains(t, err.Error(), "--profile") +} + +func TestDaemonActiveProfileForUserRejectsOtherUsersDefaultProfile(t *testing.T) { + client := &fakeActiveProfileClient{resp: &proto.GetActiveProfileResponse{Id: "default", ProfileName: "default", Username: "misha"}} + prof, err := daemonActiveProfileForUser(context.Background(), client, "root") + require.Error(t, err) + assert.Nil(t, prof) + assert.Contains(t, err.Error(), "--profile") +} + +func TestDaemonActiveProfileForUserRejectsLookupError(t *testing.T) { + client := &fakeActiveProfileClient{err: gstatus.Error(codes.Internal, "boom")} + prof, err := daemonActiveProfileForUser(context.Background(), client, "root") + require.Error(t, err) + assert.Nil(t, prof) + assert.Contains(t, err.Error(), "--profile") +} + +func TestDaemonActiveProfileForUserRejectsEmptyResponse(t *testing.T) { + client := &fakeActiveProfileClient{resp: &proto.GetActiveProfileResponse{}} + prof, err := daemonActiveProfileForUser(context.Background(), client, "root") + require.Error(t, err) + assert.Nil(t, prof) + assert.Contains(t, err.Error(), "--profile") +} + +func TestDaemonActiveProfileForUserKeepsMirrorWhenDaemonWithoutRPC(t *testing.T) { + client := &fakeActiveProfileClient{err: gstatus.Error(codes.Unimplemented, "unknown method")} + prof, err := daemonActiveProfileForUser(context.Background(), client, "root") + require.ErrorIs(t, err, errDaemonActiveProfileUnsupported) + assert.Nil(t, prof) +} diff --git a/client/collect-licenses.sh b/client/collect-licenses.sh new file mode 100644 index 000000000..7dfabada9 --- /dev/null +++ b/client/collect-licenses.sh @@ -0,0 +1,77 @@ +#!/bin/sh +set -eu + +if [ "$#" -lt 2 ]; then + printf '%s\n' "usage: $0 OUTPUT_DIRECTORY GOARCH..." >&2 + exit 2 +fi + +repo_root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +output_name=$(basename "$1") +if [ -z "$output_name" ] || [ "$output_name" = "." ] || + [ "$output_name" = ".." ] || [ "$output_name" = "/" ]; then + printf '%s\n' "OUTPUT_DIRECTORY must name a directory" >&2 + exit 2 +fi +output_parent=$(CDPATH= cd -- "$(dirname "$1")" && pwd) +output="$output_parent/$output_name" +shift +modules=$(mktemp "${TMPDIR:-/tmp}/netbird-client-licenses.modules.XXXXXX") +sorted_modules=$(mktemp "${TMPDIR:-/tmp}/netbird-client-licenses.sorted.XXXXXX") +trap 'rm -f "$modules" "$sorted_modules"' EXIT HUP INT TERM + +if [ -e "$output" ] || [ -L "$output" ]; then + printf 'output directory already exists: %s\n' "$output" >&2 + exit 1 +fi +mkdir "$output" +mkdir "$output/third_party" + +cp "$repo_root/LICENSE" "$output/BSD-3-Clause.txt" + +cd "$repo_root" +for arch in "$@"; do + GOOS=${GOOS:-linux} GOARCH="$arch" CGO_ENABLED=${CGO_ENABLED:-0} \ + go list -deps -f '{{with .Module}}{{if .Replace}}{{.Replace.Path}}{{"\t"}}{{.Replace.Version}}{{"\t"}}{{.Replace.Dir}}{{else}}{{.Path}}{{"\t"}}{{.Version}}{{"\t"}}{{.Dir}}{{end}}{{end}}' -tags load_wgnt_from_rsrc ./client >>"$modules" +done +LC_ALL=C sort -u "$modules" >"$sorted_modules" + +goroot=$(go env GOROOT) +for term in LICENSE PATENTS; do + if [ ! -f "$goroot/$term" ]; then + printf 'missing Go standard-library term: %s\n' "$goroot/$term" >&2 + exit 1 + fi + cp "$goroot/$term" "$output/Go-$term" +done + +while IFS=' ' read -r module version module_dir; do + [ -n "$module" ] || continue + [ "$module" = "github.com/netbirdio/netbird" ] && continue + + if [ -z "$version" ] || [ ! -d "$module_dir" ]; then + printf 'cannot collect terms for module %s at version %s\n' "$module" "$version" >&2 + exit 1 + fi + + destination="$output/third_party/$module/$version" + mkdir -p "$destination" + printf 'module: %s\nversion: %s\n' "$module" "$version" >"$destination/MODULE" + + found=false + for term in \ + "$module_dir"/LICENSE* "$module_dir"/License* "$module_dir"/license* \ + "$module_dir"/LICENCE* "$module_dir"/Licence* "$module_dir"/licence* \ + "$module_dir"/COPYING* "$module_dir"/Copying* "$module_dir"/copying* \ + "$module_dir"/NOTICE* "$module_dir"/Notice* "$module_dir"/notice* \ + "$module_dir"/PATENTS* "$module_dir"/Patents* "$module_dir"/patents*; do + [ -f "$term" ] || continue + cp "$term" "$destination/" + found=true + done + + if [ "$found" = false ]; then + printf 'no root license terms found for module %s at %s\n' "$module" "$module_dir" >&2 + exit 1 + fi +done <"$sorted_modules" diff --git a/client/configs/configs.go b/client/configs/configs.go index 8f9c3ba28..a1ecf0feb 100644 --- a/client/configs/configs.go +++ b/client/configs/configs.go @@ -6,6 +6,11 @@ import ( "runtime" ) +// UILogFile is the file name the desktop UI writes its log to. It is defined +// here so the UI (writer), the daemon's RegisterUILog validation, and the debug +// bundle collector all share one definition. +const UILogFile = "gui-client.log" + var StateDir string func init() { diff --git a/client/embed/embed.go b/client/embed/embed.go index 99a6b8229..5a3d540ec 100644 --- a/client/embed/embed.go +++ b/client/embed/embed.go @@ -21,7 +21,8 @@ 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" + "github.com/netbirdio/netbird/client/mdm" + 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" @@ -85,12 +86,24 @@ type Options struct { DisableIPv6 bool // BlockInbound blocks all inbound connections from peers BlockInbound bool + // EnableRosenpass enables the Rosenpass post-quantum key exchange. + EnableRosenpass bool + // RosenpassPermissive lets a Rosenpass-enabled peer still connect to peers + // that do not run Rosenpass (falling back to the plain WireGuard PSK). + RosenpassPermissive bool // BlockLANAccess blocks the embedded peer from reaching the host's // LAN (RFC 1918, link-local, loopback) when it's used as a routing // peer. Mirrors profilemanager.ConfigInput.BlockLANAccess. Useful // when the embedded client must never act as a stepping stone into // the host's local network (e.g. the proxy's overlay peer). BlockLANAccess bool + // LazyConnectionEnabled is a tri-state local override for lazy connections, + // mirroring the NB_LAZY_CONN env var. Nil defers to the management feature + // flag; a set value overrides it in both directions. A short-lived client + // that reaches only a few known peers can set this to false, so its peers + // connect eagerly and the first request does not wait for the connection to + // be established. + LazyConnectionEnabled *bool // WireguardPort is the port for the tunnel interface. Use 0 for a random port. WireguardPort *int // MTU is the MTU for the tunnel interface. @@ -203,6 +216,8 @@ func New(opts Options) (*Client, error) { DisableIPv6: &opts.DisableIPv6, BlockInbound: &opts.BlockInbound, BlockLANAccess: &opts.BlockLANAccess, + RosenpassEnabled: &opts.EnableRosenpass, + RosenpassPermissive: &opts.RosenpassPermissive, WireguardPort: opts.WireguardPort, MTU: opts.MTU, DNSLabels: parsedLabels, @@ -215,11 +230,24 @@ func New(opts Options) (*Client, error) { if err != nil { return nil, fmt.Errorf("create config: %w", err) } + // Embedded path runs without the daemon Server: apply the active + // MDM policy explicitly so a forced ManagementURL / PSK / other + // managed key takes effect on this embedded engine instance. + config.ApplyMDMPolicy(mdm.NewLoader(nil).Load()) if opts.PrivateKey != "" { config.PrivateKey = opts.PrivateKey } + if opts.LazyConnectionEnabled != nil { + // Runtime-only override, read back through lazyconn.ParseState; a set value + // wins over the management feature flag in both directions. + config.LazyConnection = "off" + if *opts.LazyConnectionEnabled { + config.LazyConnection = "on" + } + } + if opts.Performance.PreallocatedBuffersPerPool != nil { wgdevice.SetPreallocatedBuffersPerPool(*opts.Performance.PreallocatedBuffersPerPool) } @@ -521,12 +549,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 diff --git a/client/embed/embed_test.go b/client/embed/embed_test.go index a2f438975..4ff5c9978 100644 --- a/client/embed/embed_test.go +++ b/client/embed/embed_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/stretchr/testify/require" "google.golang.org/grpc" @@ -146,7 +146,7 @@ func startManagement(t *testing.T, signalAddr string) string { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := mgmt.NewAccountRequestBuffer(context.Background(), testStore) - networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(testStore, peersManager), cfg) + networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(testStore, peersManager), cfg, nil) accountManager, err := mgmt.BuildManager(context.Background(), cfg, testStore, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) require.NoError(t, err) diff --git a/client/firewall/allower_other.go b/client/firewall/allower_other.go new file mode 100644 index 000000000..4d2ec9094 --- /dev/null +++ b/client/firewall/allower_other.go @@ -0,0 +1,11 @@ +//go:build android || (!linux && !windows) + +package firewall + +import "github.com/netbirdio/netbird/client/firewall/uspfilter" + +// interfaceAllower returns no allower: these platforms have no host firewall to +// open for the interface. +func interfaceAllower(IFaceMapper, uint16) uspfilter.InterfaceAllower { + return nil +} diff --git a/client/firewall/allower_windows.go b/client/firewall/allower_windows.go new file mode 100644 index 000000000..b9efa18a4 --- /dev/null +++ b/client/firewall/allower_windows.go @@ -0,0 +1,10 @@ +//go:build windows + +package firewall + +import "github.com/netbirdio/netbird/client/firewall/uspfilter" + +// interfaceAllower returns the Windows netsh-based interface allower. +func interfaceAllower(iface IFaceMapper, _ uint16) uspfilter.InterfaceAllower { + return uspfilter.NewWindowsInterfaceAllower(iface) +} diff --git a/client/firewall/create.go b/client/firewall/create.go index 24f12bc6d..cb68a0d04 100644 --- a/client/firewall/create.go +++ b/client/firewall/create.go @@ -6,8 +6,6 @@ import ( "fmt" "runtime" - log "github.com/sirupsen/logrus" - firewall "github.com/netbirdio/netbird/client/firewall/manager" "github.com/netbirdio/netbird/client/firewall/uspfilter" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" @@ -21,13 +19,11 @@ func NewFirewall(iface IFaceMapper, _ *statemanager.Manager, flowLogger nftypes. } // use userspace packet filtering firewall - fm, err := uspfilter.Create(iface, disableServerRoutes, flowLogger, mtu) - if err != nil { - return nil, err - } - err = fm.AllowNetbird() - if err != nil { - log.Warnf("failed to allow netbird interface traffic: %v", err) - } - return fm, nil + return uspfilter.Create(uspfilter.Config{ + IFace: iface, + DisableServerRoutes: disableServerRoutes, + FlowLogger: flowLogger, + MTU: mtu, + InterfaceAllower: interfaceAllower(iface, mtu), + }) } diff --git a/client/firewall/create_linux.go b/client/firewall/create_linux.go index d916ebad4..d585e85d7 100644 --- a/client/firewall/create_linux.go +++ b/client/firewall/create_linux.go @@ -16,6 +16,7 @@ import ( firewall "github.com/netbirdio/netbird/client/firewall/manager" nbnftables "github.com/netbirdio/netbird/client/firewall/nftables" "github.com/netbirdio/netbird/client/firewall/uspfilter" + "github.com/netbirdio/netbird/client/iface/netstack" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" "github.com/netbirdio/netbird/client/internal/statemanager" ) @@ -29,47 +30,107 @@ const ( NFTABLES ) -// SKIP_NFTABLES_ENV is the environment variable to skip nftables check -const SKIP_NFTABLES_ENV = "NB_SKIP_NFTABLES_CHECK" +// SkipNftablesEnv is the environment variable to skip nftables check +const SkipNftablesEnv = "NB_SKIP_NFTABLES_CHECK" + +// errNoFirewallManager indicates no kernel firewall backend is present, +// as opposed to a backend that exists but failed to create or initialize. +var errNoFirewallManager = errors.New("no firewall manager found") // FWType is the type for the firewall type type FWType int func NewFirewall(iface IFaceMapper, stateManager *statemanager.Manager, flowLogger nftypes.FlowLogger, disableServerRoutes bool, mtu uint16) (firewall.Manager, error) { - // We run in userspace mode and force userspace firewall was requested. We don't attempt native firewall. - if iface.IsUserspaceBind() && forceUserspaceFirewall() { - log.Info("forcing userspace firewall") - return createUserspaceFirewall(iface, nil, disableServerRoutes, flowLogger, mtu) + // Userspace firewall without a native counterpart: routing is handled + // entirely in userspace. The interface is opened in the kernel's foreign + // filter chains via a table-less allower, except in netstack mode where no + // kernel interface exists. + if netstack.IsEnabled() || (iface.IsUserspaceBind() && forceUserspaceFirewall()) { + if netstack.IsEnabled() { + log.Info("netstack mode, using userspace firewall") + } else { + log.Info("forcing userspace firewall") + } + cfg := uspfilter.Config{ + IFace: iface, + DisableServerRoutes: disableServerRoutes, + FlowLogger: flowLogger, + MTU: mtu, + InterfaceAllower: interfaceAllower(iface, mtu), + } + + return uspfilter.Create(cfg) } // Use native firewall for either kernel or userspace, the interface appears identical to netfilter - fm, err := createNativeFirewall(iface, stateManager, disableServerRoutes, mtu) - - // Kernel cannot fall back to anything else, need to return error - if !iface.IsUserspaceBind() { - return fm, err - } - - // Fall back to the userspace packet filter if native is unavailable - if err != nil { - log.Warnf("failed to create native firewall: %v. Proceeding with userspace", err) - return createUserspaceFirewall(iface, nil, disableServerRoutes, flowLogger, mtu) - } - - // Native firewall handles packet filtering, but the userspace WireGuard bind - // needs a device filter for DNS interception hooks. Install a minimal - // hooks-only filter that passes all traffic through to the kernel firewall. - if err := iface.SetFilter(&uspfilter.HooksFilter{}); err != nil { - log.Warnf("failed to set hooks filter, DNS via memory hooks will not work: %v", err) + fm, err := createNativeFirewall(iface, stateManager, mtu) + switch { + case err == nil && !iface.IsUserspaceBind(): + // Nothing to do, fall through + case err == nil && iface.IsUserspaceBind(): + // Native firewall handles packet filtering, but the userspace WireGuard bind + // needs a device filter for DNS interception hooks. Install a minimal + // hooks-only filter that passes all traffic through to the kernel firewall. + if err := iface.SetFilter(&uspfilter.HooksFilter{}); err != nil { + log.Warnf("failed to set hooks filter, DNS via memory hooks will not work: %v", err) + } + case err != nil && !iface.IsUserspaceBind(): + // Kernel cannot fall back to anything else, need to return error + return nil, err + case err != nil && iface.IsUserspaceBind(): + // Fall back to the userspace packet filter if native is unavailable + logNativeFirewallUnavailable(err) + return uspfilter.Create(uspfilter.Config{ + IFace: iface, + DisableServerRoutes: disableServerRoutes, + FlowLogger: flowLogger, + MTU: mtu, + InterfaceAllower: interfaceAllower(iface, mtu), + }) } return fm, nil } -func createNativeFirewall(iface IFaceMapper, stateManager *statemanager.Manager, routes bool, mtu uint16) (firewall.Manager, error) { +// interfaceAllower selects how the userspace firewall opens the interface in +// foreign kernel chains: nftables when available (which also opens foreign nft +// tables), else iptables (the legacy fallback, filter INPUT only), else nil. +// firewalld trust is applied separately by the manager. Netstack has no kernel +// interface to open. +func interfaceAllower(iface IFaceMapper, mtu uint16) uspfilter.InterfaceAllower { + if netstack.IsEnabled() { + return nil + } + + nftAllower, err := nbnftables.NewInterfaceAllower(iface, mtu) + if err == nil { + return nftAllower + } + log.Infof("no nftables interface allower: %v", err) + + iptAllower, err := nbiptables.NewInterfaceAllower(iface) + if err == nil { + return iptAllower + } + log.Infof("no iptables interface allower: %v", err) + + return nil +} + +// logNativeFirewallUnavailable logs the fallback to userspace at info level +// when no kernel firewall backend exists, and at warn level otherwise. +func logNativeFirewallUnavailable(err error) { + if errors.Is(err, errNoFirewallManager) { + log.Infof("no native firewall backend available: %v. Proceeding with userspace", err) + } else { + log.Warnf("failed to create native firewall: %v. Proceeding with userspace", err) + } +} + +func createNativeFirewall(iface IFaceMapper, stateManager *statemanager.Manager, mtu uint16) (firewall.Manager, error) { fm, err := createFW(iface, mtu) if err != nil { - return nil, fmt.Errorf("create firewall: %s", err) + return nil, fmt.Errorf("create firewall: %w", err) } if err = fm.Init(stateManager); err != nil { @@ -88,29 +149,10 @@ func createFW(iface IFaceMapper, mtu uint16) (firewall.Manager, error) { log.Info("creating an nftables firewall manager") return nbnftables.Create(iface, mtu) default: - log.Info("no firewall manager found, trying to use userspace packet filtering firewall") - return nil, errors.New("no firewall manager found") + return nil, errNoFirewallManager } } -func createUserspaceFirewall(iface IFaceMapper, fm firewall.Manager, disableServerRoutes bool, flowLogger nftypes.FlowLogger, mtu uint16) (firewall.Manager, error) { - var errUsp error - if fm != nil { - fm, errUsp = uspfilter.CreateWithNativeFirewall(iface, fm, disableServerRoutes, flowLogger, mtu) - } else { - fm, errUsp = uspfilter.Create(iface, disableServerRoutes, flowLogger, mtu) - } - - if errUsp != nil { - return nil, fmt.Errorf("create userspace firewall: %s", errUsp) - } - - if err := fm.AllowNetbird(); err != nil { - log.Errorf("failed to allow netbird interface traffic: %v", err) - } - return fm, nil -} - // check returns the firewall type based on common lib checks. It returns UNKNOWN if no firewall is found. func check() FWType { useIPTABLES := false @@ -132,35 +174,38 @@ func check() FWType { } } - nf := nftables.Conn{} - if chains, err := nf.ListChains(); err == nil && os.Getenv(SKIP_NFTABLES_ENV) != "true" { - if !useIPTABLES { - return NFTABLES - } - - // search for chains where table is filter - // if we find one, we assume that nftables manager can be used with iptables - for _, chain := range chains { - if chain.Table.Name == "filter" { + // Honor the skip env before probing nftables at all. + if os.Getenv(SkipNftablesEnv) != "true" { + nf := nftables.Conn{} + if chains, err := nf.ListChains(); err == nil { + if !useIPTABLES { return NFTABLES } - } - // check tables for the following constraints: - // 1. there is no chain in nftables for the filter table and there is at least one chain in iptables, we assume that nftables manager can not be used - // 2. there is no tables or more than one table, we assume that nftables manager can be used - // 3. there is only one table and its name is filter, we assume that nftables manager can not be used, since there was no chain in it - // 4. if we find an error we log and continue with iptables check - nbTablesList, err := nf.ListTables() - switch { - case err == nil && len(iptablesChains) > 0: - return IPTABLES - case err == nil && len(nbTablesList) != 1: - return NFTABLES - case err == nil && len(nbTablesList) == 1 && nbTablesList[0].Name == "filter": - return IPTABLES - case err != nil: - log.Errorf("failed to list nftables tables on fw manager discovery: %s", err) + // search for chains where table is filter + // if we find one, we assume that nftables manager can be used with iptables + for _, chain := range chains { + if chain.Table.Name == "filter" { + return NFTABLES + } + } + + // check tables for the following constraints: + // 1. there is no chain in nftables for the filter table and there is at least one chain in iptables, we assume that nftables manager can not be used + // 2. there is no tables or more than one table, we assume that nftables manager can be used + // 3. there is only one table and its name is filter, we assume that nftables manager can not be used, since there was no chain in it + // 4. if we find an error we log and continue with iptables check + nbTablesList, err := nf.ListTables() + switch { + case err == nil && len(iptablesChains) > 0: + return IPTABLES + case err == nil && len(nbTablesList) != 1: + return NFTABLES + case err == nil && len(nbTablesList) == 1 && nbTablesList[0].Name == "filter": + return IPTABLES + case err != nil: + log.Errorf("failed to list nftables tables on fw manager discovery: %s", err) + } } } @@ -176,15 +221,21 @@ func isIptablesClientAvailable(client *iptables.IPTables) bool { return err == nil } +// forceUserspaceFirewall reports whether the userspace firewall is forced. +// NB_FORCE_USERSPACE_ROUTER is an alias: forcing userspace routing implies the +// userspace firewall, since the two are no longer separable. func forceUserspaceFirewall() bool { - val := os.Getenv(EnvForceUserspaceFirewall) + return envForceBool(EnvForceUserspaceFirewall) || envForceBool(uspfilter.EnvForceUserspaceRouter) +} + +func envForceBool(name string) bool { + val := os.Getenv(name) if val == "" { return false } - force, err := strconv.ParseBool(val) if err != nil { - log.Warnf("failed to parse %s: %v", EnvForceUserspaceFirewall, err) + log.Warnf("failed to parse %s: %v", name, err) return false } return force diff --git a/client/firewall/firewalld/firewalld.go b/client/firewall/firewalld/firewalld.go index 188ea61dd..38a4efdbc 100644 --- a/client/firewall/firewalld/firewalld.go +++ b/client/firewall/firewalld/firewalld.go @@ -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 diff --git a/client/firewall/iptables/acl_linux.go b/client/firewall/iptables/acl_linux.go deleted file mode 100644 index 4b4cebf9c..000000000 --- a/client/firewall/iptables/acl_linux.go +++ /dev/null @@ -1,560 +0,0 @@ -package iptables - -import ( - "errors" - "fmt" - "maps" - "net" - "slices" - - "github.com/coreos/go-iptables/iptables" - "github.com/google/uuid" - ipset "github.com/lrh3321/ipset-go" - log "github.com/sirupsen/logrus" - - firewall "github.com/netbirdio/netbird/client/firewall/manager" - "github.com/netbirdio/netbird/client/internal/statemanager" - nbnet "github.com/netbirdio/netbird/client/net" -) - -const ( - tableName = "filter" - - // rules chains contains the effective ACL rules - chainNameInputRules = "NETBIRD-ACL-INPUT" - - // mangleFwdKey is the entries map key for mangle FORWARD guard rules that prevent - // external DNAT from bypassing ACL rules. - mangleFwdKey = "MANGLE-FORWARD" -) - -type aclEntries map[string][][]string - -type entry struct { - spec []string - position int -} - -type aclManager struct { - iptablesClient *iptables.IPTables - wgIface iFaceMapper - entries aclEntries - optionalEntries map[string][]entry - ipsetStore *ipsetStore - v6 bool - - stateManager *statemanager.Manager -} - -func newAclManager(iptablesClient *iptables.IPTables, wgIface iFaceMapper) (*aclManager, error) { - return &aclManager{ - iptablesClient: iptablesClient, - wgIface: wgIface, - entries: make(map[string][][]string), - optionalEntries: make(map[string][]entry), - ipsetStore: newIpsetStore(), - v6: iptablesClient.Proto() == iptables.ProtocolIPv6, - }, nil -} - -func (m *aclManager) init(stateManager *statemanager.Manager) error { - m.stateManager = stateManager - - m.seedInitialEntries() - m.seedInitialOptionalEntries() - - if err := m.cleanChains(); err != nil { - return fmt.Errorf("clean chains: %w", err) - } - - if err := m.createDefaultChains(); err != nil { - return fmt.Errorf("create default chains: %w", err) - } - - m.updateState() - - return nil -} - -func (m *aclManager) AddPeerFiltering( - id []byte, - ip net.IP, - protocol firewall.Protocol, - sPort *firewall.Port, - dPort *firewall.Port, - action firewall.Action, - ipsetName string, -) ([]firewall.Rule, error) { - chain := chainNameInputRules - - ipsetName = transformIPsetName(ipsetName, sPort, dPort, action) - if m.v6 && ipsetName != "" { - ipsetName += "-v6" - } - proto := protoForFamily(protocol, m.v6) - specs := filterRuleSpecs(ip, proto, sPort, dPort, action, ipsetName) - - mangleSpecs := slices.Clone(specs) - mangleSpecs = append(mangleSpecs, - "-i", m.wgIface.Name(), - "-m", "addrtype", "--dst-type", "LOCAL", - "-j", "MARK", "--set-xmark", fmt.Sprintf("%#x", nbnet.PreroutingFwmarkRedirected), - ) - - specs = append(specs, "-j", actionToStr(action)) - if ipsetName != "" { - if ipList, ipsetExists := m.ipsetStore.ipset(ipsetName); ipsetExists { - if err := m.addToIPSet(ipsetName, ip); err != nil { - return nil, fmt.Errorf("add IP to ipset: %w", err) - } - // if ruleset already exists it means we already have the firewall rule - // so we need to update IPs in the ruleset and return new fw.Rule object for ACL manager. - ipList.addIP(ip.String()) - return []firewall.Rule{&Rule{ - ruleID: uuid.New().String(), - ipsetName: ipsetName, - ip: ip.String(), - chain: chain, - specs: specs, - v6: m.v6, - }}, nil - } - - if err := m.flushIPSet(ipsetName); err != nil { - if errors.Is(err, ipset.ErrSetNotExist) { - log.Debugf("flush ipset %s before use: %v", ipsetName, err) - } else { - log.Errorf("flush ipset %s before use: %v", ipsetName, err) - } - } - if err := m.createIPSet(ipsetName); err != nil { - return nil, fmt.Errorf("create ipset: %w", err) - } - if err := m.addToIPSet(ipsetName, ip); err != nil { - return nil, fmt.Errorf("add IP to ipset: %w", err) - } - - ipList := newIpList(ip.String()) - m.ipsetStore.addIpList(ipsetName, ipList) - } - - ok, err := m.iptablesClient.Exists(tableFilter, chain, specs...) - if err != nil { - return nil, fmt.Errorf("failed to check rule: %w", err) - } - if ok { - return nil, fmt.Errorf("rule already exists") - } - - // Insert DROP rules at the beginning, append ACCEPT rules at the end - if action == firewall.ActionDrop { - // Insert at the beginning of the chain (position 1) - err = m.iptablesClient.Insert(tableFilter, chain, 1, specs...) - } else { - err = m.iptablesClient.Append(tableFilter, chain, specs...) - } - if err != nil { - return nil, err - } - - if err := m.iptablesClient.Append(tableMangle, chainRTPRE, mangleSpecs...); err != nil { - log.Errorf("failed to add mangle rule: %v", err) - mangleSpecs = nil - } - - rule := &Rule{ - ruleID: uuid.New().String(), - specs: specs, - mangleSpecs: mangleSpecs, - ipsetName: ipsetName, - ip: ip.String(), - chain: chain, - v6: m.v6, - } - - m.updateState() - - return []firewall.Rule{rule}, nil -} - -// DeletePeerRule from the firewall by rule definition -func (m *aclManager) DeletePeerRule(rule firewall.Rule) error { - r, ok := rule.(*Rule) - if !ok { - return fmt.Errorf("invalid rule type") - } - - shouldDestroyIpset := false - if ipsetList, ok := m.ipsetStore.ipset(r.ipsetName); ok { - // delete IP from ruleset IPs list and ipset - if _, ok := ipsetList.ips[r.ip]; ok { - ip := net.ParseIP(r.ip) - if ip == nil { - return fmt.Errorf("parse IP %s", r.ip) - } - if err := m.delFromIPSet(r.ipsetName, ip); err != nil { - return fmt.Errorf("delete ip from ipset: %w", err) - } - delete(ipsetList.ips, r.ip) - } - - // if after delete, set still contains other IPs, - // no need to delete firewall rule and we should exit here - if len(ipsetList.ips) != 0 { - return nil - } - - // we delete last IP from the set, that means we need to delete - // set itself and associated firewall rule too - m.ipsetStore.deleteIpset(r.ipsetName) - shouldDestroyIpset = true - } - - if err := m.iptablesClient.Delete(tableName, r.chain, r.specs...); err != nil { - return fmt.Errorf("failed to delete rule: %s, %v: %w", r.chain, r.specs, err) - } - - if r.mangleSpecs != nil { - if err := m.iptablesClient.Delete(tableMangle, chainRTPRE, r.mangleSpecs...); err != nil { - log.Errorf("failed to delete mangle rule: %v", err) - } - } - - if shouldDestroyIpset { - if err := m.destroyIPSet(r.ipsetName); err != nil { - if errors.Is(err, ipset.ErrBusy) || errors.Is(err, ipset.ErrSetNotExist) { - log.Debugf("destroy empty ipset: %v", err) - } else { - log.Errorf("destroy empty ipset: %v", err) - } - } - } - - m.updateState() - - return nil -} - -func (m *aclManager) Reset() error { - if err := m.cleanChains(); err != nil { - return fmt.Errorf("clean chains: %w", err) - } - - m.updateState() - - return nil -} - -// todo write less destructive cleanup mechanism -func (m *aclManager) cleanChains() error { - ok, err := m.iptablesClient.ChainExists(tableName, chainNameInputRules) - if err != nil { - log.Debugf("failed to list chains: %s", err) - return err - } - if ok { - for _, rule := range m.entries["INPUT"] { - err := m.iptablesClient.DeleteIfExists(tableName, "INPUT", rule...) - if err != nil { - log.Errorf("failed to delete rule: %v, %s", rule, err) - } - } - - for _, rule := range m.entries["FORWARD"] { - err := m.iptablesClient.DeleteIfExists(tableName, "FORWARD", rule...) - if err != nil { - log.Errorf("failed to delete rule: %v, %s", rule, err) - } - } - - err = m.iptablesClient.ClearAndDeleteChain(tableName, chainNameInputRules) - if err != nil { - log.Debugf("failed to clear and delete %s chain: %s", chainNameInputRules, err) - return err - } - } - - ok, err = m.iptablesClient.ChainExists("mangle", "PREROUTING") - if err != nil { - return fmt.Errorf("list chains: %w", err) - } - if ok { - for _, rule := range m.entries["PREROUTING"] { - err := m.iptablesClient.DeleteIfExists("mangle", "PREROUTING", rule...) - if err != nil { - log.Errorf("failed to delete rule: %v, %s", rule, err) - } - } - } - - for _, rule := range m.entries[mangleFwdKey] { - if err := m.iptablesClient.DeleteIfExists(tableMangle, chainFORWARD, rule...); err != nil { - log.Errorf("failed to delete mangle FORWARD guard rule: %v, %s", rule, err) - } - } - - for _, ipsetName := range m.ipsetStore.ipsetNames() { - if err := m.flushIPSet(ipsetName); err != nil { - if errors.Is(err, ipset.ErrSetNotExist) { - log.Debugf("flush ipset %q during reset: %v", ipsetName, err) - } else { - log.Errorf("flush ipset %q during reset: %v", ipsetName, err) - } - } - if err := m.destroyIPSet(ipsetName); err != nil { - if errors.Is(err, ipset.ErrBusy) || errors.Is(err, ipset.ErrSetNotExist) { - log.Debugf("destroy ipset %q during reset: %v", ipsetName, err) - } else { - log.Errorf("destroy ipset %q during reset: %v", ipsetName, err) - } - } - m.ipsetStore.deleteIpset(ipsetName) - } - - return nil -} - -func (m *aclManager) createDefaultChains() error { - // chain netbird-acl-input-rules - if err := m.iptablesClient.NewChain(tableName, chainNameInputRules); err != nil { - log.Debugf("failed to create '%s' chain: %s", chainNameInputRules, err) - return err - } - - for chainName, rules := range m.entries { - // mangle FORWARD guard rules are handled separately below - if chainName == mangleFwdKey { - continue - } - for _, rule := range rules { - if err := m.iptablesClient.InsertUnique(tableName, chainName, 1, rule...); err != nil { - log.Debugf("failed to create input chain jump rule: %s", err) - return err - } - } - } - - for chainName, entries := range m.optionalEntries { - for _, entry := range entries { - if err := m.iptablesClient.InsertUnique(tableName, chainName, entry.position, entry.spec...); err != nil { - log.Errorf("failed to insert optional entry %v: %v", entry.spec, err) - continue - } - m.entries[chainName] = append(m.entries[chainName], entry.spec) - } - } - clear(m.optionalEntries) - - // Insert mangle FORWARD guard rules to prevent external DNAT bypass. - for _, rule := range m.entries[mangleFwdKey] { - if err := m.iptablesClient.AppendUnique(tableMangle, chainFORWARD, rule...); err != nil { - log.Errorf("failed to add mangle FORWARD guard rule: %v", err) - } - } - - return nil -} - -// seedInitialEntries adds default rules to the entries map, rules are inserted on pos 1, hence the order is reversed. -// We want to make sure our traffic is not dropped by existing rules. - -// The existing FORWARD rules/policies decide outbound traffic towards our interface. -// In case the FORWARD policy is set to "drop", we add an established/related rule to allow return traffic for the inbound rule. -func (m *aclManager) seedInitialEntries() { - established := getConntrackEstablished() - - m.appendToEntries("INPUT", []string{"-i", m.wgIface.Name(), "-j", "DROP"}) - m.appendToEntries("INPUT", []string{"-i", m.wgIface.Name(), "-j", chainNameInputRules}) - m.appendToEntries("INPUT", append([]string{"-i", m.wgIface.Name()}, established...)) - - // Inbound is handled by our ACLs, the rest is dropped. - // For outbound we respect the FORWARD policy. However, we need to allow established/related traffic for inbound rules. - m.appendToEntries("FORWARD", []string{"-i", m.wgIface.Name(), "-j", "DROP"}) - - m.appendToEntries("FORWARD", []string{"-o", m.wgIface.Name(), "-j", chainRTFWDOUT}) - m.appendToEntries("FORWARD", []string{"-i", m.wgIface.Name(), "-j", chainRTFWDIN}) - - // Mangle FORWARD guard: when external DNAT redirects traffic from the wg interface, it - // traverses FORWARD instead of INPUT, bypassing ACL rules. ACCEPT rules in filter FORWARD - // can be inserted above ours. Mangle runs before filter, so these guard rules enforce the - // ACL mark check where it cannot be overridden. - m.appendToEntries(mangleFwdKey, []string{ - "-i", m.wgIface.Name(), - "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", - "-j", "ACCEPT", - }) - m.appendToEntries(mangleFwdKey, []string{ - "-i", m.wgIface.Name(), - "-m", "conntrack", "--ctstate", "DNAT", - "-m", "mark", "!", "--mark", fmt.Sprintf("%#x", nbnet.PreroutingFwmarkRedirected), - "-j", "DROP", - }) -} - -func (m *aclManager) seedInitialOptionalEntries() { - m.optionalEntries["FORWARD"] = []entry{ - { - spec: []string{"-m", "mark", "--mark", fmt.Sprintf("%#x", nbnet.PreroutingFwmarkRedirected), "-j", "ACCEPT"}, - position: 2, - }, - } -} - -func (m *aclManager) appendToEntries(chainName string, spec []string) { - m.entries[chainName] = append(m.entries[chainName], spec) -} - -func (m *aclManager) updateState() { - if m.stateManager == nil { - return - } - - var currentState *ShutdownState - if existing := m.stateManager.GetState(currentState); existing != nil { - if existingState, ok := existing.(*ShutdownState); ok { - currentState = existingState - } - } - if currentState == nil { - currentState = &ShutdownState{} - } - - currentState.Lock() - defer currentState.Unlock() - - // Clone the maps so the persisted state holds a private snapshot. The - // live maps keep being mutated by subsequent rule operations while the - // state manager marshals the state from its periodic-save goroutine. - // Sharing them by reference races the two and aborts the process with a - // concurrent map iteration and write. - if m.v6 { - currentState.ACLEntries6 = maps.Clone(m.entries) - currentState.ACLIPsetStore6 = m.ipsetStore.clone() - } else { - currentState.ACLEntries = maps.Clone(m.entries) - currentState.ACLIPsetStore = m.ipsetStore.clone() - } - - if err := m.stateManager.UpdateState(currentState); err != nil { - log.Errorf("failed to update state: %v", err) - } -} - -// filterRuleSpecs returns the specs of a filtering rule -// protoForFamily translates ICMP to ICMPv6 for ip6tables. -// ip6tables requires "ipv6-icmp" (or "icmpv6") instead of "icmp". -func protoForFamily(protocol firewall.Protocol, v6 bool) string { - if v6 && protocol == firewall.ProtocolICMP { - return "ipv6-icmp" - } - return string(protocol) -} - -func filterRuleSpecs(ip net.IP, protocol string, sPort, dPort *firewall.Port, action firewall.Action, ipsetName string) (specs []string) { - // don't use IP matching if IP is 0.0.0.0 - matchByIP := !ip.IsUnspecified() - - if matchByIP { - if ipsetName != "" { - specs = append(specs, "-m", "set", "--match-set", ipsetName, "src") - } else { - specs = append(specs, "-s", ip.String()) - } - } - if protocol != "all" { - specs = append(specs, "-p", protocol) - } - specs = append(specs, applyPort("--sport", sPort)...) - specs = append(specs, applyPort("--dport", dPort)...) - return specs -} - -func actionToStr(action firewall.Action) string { - if action == firewall.ActionAccept { - return "ACCEPT" - } - return "DROP" -} - -func transformIPsetName(ipsetName string, sPort, dPort *firewall.Port, action firewall.Action) string { - if ipsetName == "" { - return "" - } - - actionSuffix := "" - if action == firewall.ActionDrop { - actionSuffix = "-drop" - } - - switch { - case sPort != nil && dPort != nil: - return ipsetName + "-sport-dport" + actionSuffix - case sPort != nil: - return ipsetName + "-sport" + actionSuffix - case dPort != nil: - return ipsetName + "-dport" + actionSuffix - default: - return ipsetName + actionSuffix - } -} - -func (m *aclManager) createIPSet(name string) error { - opts := ipset.CreateOptions{ - Replace: true, - } - if m.v6 { - opts.Family = ipset.FamilyIPV6 - } - - if err := ipset.Create(name, ipset.TypeHashNet, opts); err != nil { - return fmt.Errorf("create ipset %s: %w", name, err) - } - - log.Debugf("created ipset %s with type hash:net", name) - return nil -} - -func (m *aclManager) addToIPSet(name string, ip net.IP) error { - cidr := uint8(32) - if ip.To4() == nil { - cidr = 128 - } - - entry := &ipset.Entry{ - IP: ip, - CIDR: cidr, - Replace: true, - } - - if err := ipset.Add(name, entry); err != nil { - return fmt.Errorf("add IP to ipset %s: %w", name, err) - } - - return nil -} - -func (m *aclManager) delFromIPSet(name string, ip net.IP) error { - cidr := uint8(32) - if ip.To4() == nil { - cidr = 128 - } - - entry := &ipset.Entry{ - IP: ip, - CIDR: cidr, - } - - if err := ipset.Del(name, entry); err != nil { - return fmt.Errorf("delete IP from ipset %s: %w", name, err) - } - - return nil -} - -func (m *aclManager) flushIPSet(name string) error { - return ipset.Flush(name) -} - -func (m *aclManager) destroyIPSet(name string) error { - return ipset.Destroy(name) -} diff --git a/client/firewall/iptables/chains_linux.go b/client/firewall/iptables/chains_linux.go new file mode 100644 index 000000000..58bfa8c6a --- /dev/null +++ b/client/firewall/iptables/chains_linux.go @@ -0,0 +1,346 @@ +//go:build !android + +package iptables + +import ( + "fmt" + + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + + nberrors "github.com/netbirdio/netbird/client/errors" + firewall "github.com/netbirdio/netbird/client/firewall/manager" + nbnet "github.com/netbirdio/netbird/client/net" +) + +func (r *family) createContainers() error { + for _, chainInfo := range []struct { + chain string + table string + }{ + {chainRTFwdIn, tableFilter}, + {chainRTFwdOut, tableFilter}, + {chainRTPre, tableMangle}, + {chainRTNAT, tableNat}, + {chainRTRdr, tableNat}, + {chainRTMSSClamp, tableMangle}, + } { + // Fallback: clear chains that survived an unclean shutdown. + if ok, _ := r.iptablesClient.ChainExists(chainInfo.table, chainInfo.chain); ok { + if err := r.iptablesClient.ClearAndDeleteChain(chainInfo.table, chainInfo.chain); err != nil { + log.Warnf("clear stale chain %s in %s: %v", chainInfo.chain, chainInfo.table, err) + } + } + if err := r.iptablesClient.NewChain(chainInfo.table, chainInfo.chain); err != nil { + return fmt.Errorf("create chain %s in table %s: %w", chainInfo.chain, chainInfo.table, err) + } + } + + if err := r.insertEstablishedRule(chainRTFwdIn); err != nil { + return fmt.Errorf("insert established rule: %w", err) + } + + if err := r.insertEstablishedRule(chainRTFwdOut); err != nil { + return fmt.Errorf("insert established rule: %w", err) + } + + if err := r.addPostroutingRules(); err != nil { + return fmt.Errorf("add static nat rules: %w", err) + } + + if err := r.addJumpRules(); err != nil { + return fmt.Errorf("add jump rules: %w", err) + } + + if err := r.addMSSClampingRules(); err != nil { + log.Errorf("failed to add MSS clamping rules: %s", err) + } + + return nil +} + +func (r *family) addJumpRules() error { + // Jump to nat chain + natRule := jumpRuleSpec(chainRTNAT) + if err := r.iptablesClient.Insert(tableNat, chainPostrouting, 1, natRule...); err != nil { + return fmt.Errorf("add nat postrouting jump rule: %w", err) + } + r.rules[jumpNATPost] = natRule + + // Jump to mangle prerouting chain + preRule := jumpRuleSpec(chainRTPre) + if err := r.iptablesClient.Insert(tableMangle, chainPrerouting, 1, preRule...); err != nil { + return fmt.Errorf("add mangle prerouting jump rule: %w", err) + } + r.rules[jumpManglePre] = preRule + + // Jump to nat prerouting chain + rdrRule := jumpRuleSpec(chainRTRdr) + if err := r.iptablesClient.Insert(tableNat, chainPrerouting, 1, rdrRule...); err != nil { + return fmt.Errorf("add nat prerouting jump rule: %w", err) + } + r.rules[jumpNATPre] = rdrRule + + return nil +} + +func (r *family) setupDataPlaneMark() error { + var merr *multierror.Error + preRule := []string{ + "-i", r.wgIface.Name(), + "-m", "conntrack", "--ctstate", "NEW", + "-j", "CONNMARK", "--set-mark", fmt.Sprintf("%#x", nbnet.DataPlaneMarkIn), + } + + if err := r.iptablesClient.AppendUnique(tableMangle, chainPrerouting, preRule...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("add mangle prerouting rule: %w", err)) + } else { + r.rules[markManglePre] = preRule + } + + postRule := []string{ + "-o", r.wgIface.Name(), + "-m", "conntrack", "--ctstate", "NEW", + "-j", "CONNMARK", "--set-mark", fmt.Sprintf("%#x", nbnet.DataPlaneMarkOut), + } + + if err := r.iptablesClient.AppendUnique(tableMangle, chainPostrouting, postRule...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("add mangle postrouting rule: %w", err)) + } else { + r.rules[markManglePost] = postRule + } + + return nberrors.FormatErrorOrNil(merr) +} + +// seedInitialEntries adds default rules to the entries map. Rules are +// inserted at position 1, so the order here is reversed. +// +// Existing FORWARD policy decides outbound traffic towards our +// interface. If FORWARD policy is "drop", we add an +// established/related rule to allow return traffic for inbound rules. +func (r *family) seedInitialEntries() { + established := getConntrackEstablished() + + r.appendToEntries(chainInput, []string{"-i", r.wgIface.Name(), "-j", "DROP"}) + r.appendToEntries(chainInput, []string{"-i", r.wgIface.Name(), "-j", chainACLInput}) + r.appendToEntries(chainInput, append([]string{"-i", r.wgIface.Name()}, established...)) + + r.appendToEntries(chainForward, []string{"-i", r.wgIface.Name(), "-j", "DROP"}) + r.appendToEntries(chainForward, []string{"-o", r.wgIface.Name(), "-j", chainRTFwdOut}) + r.appendToEntries(chainForward, []string{"-i", r.wgIface.Name(), "-j", chainRTFwdIn}) + + // Mangle FORWARD guard: when external DNAT redirects traffic from + // the wg interface, it traverses FORWARD instead of INPUT, + // bypassing ACL rules. ACCEPT rules in filter FORWARD can be + // inserted above ours. Mangle runs before filter, so these guard + // rules enforce the ACL mark check where it cannot be overridden. + r.appendToEntries(mangleForwardKey, []string{ + "-i", r.wgIface.Name(), + "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", + "-j", "ACCEPT", + }) + r.appendToEntries(mangleForwardKey, []string{ + "-i", r.wgIface.Name(), + "-m", "conntrack", "--ctstate", "DNAT", + "-m", "mark", "!", "--mark", fmt.Sprintf("%#x", nbnet.PreroutingFwmarkRedirected), + "-j", "DROP", + }) +} + +func (r *family) seedInitialOptionalEntries() { + r.optionalEntries[chainForward] = []entry{ + { + spec: []string{"-m", "mark", "--mark", fmt.Sprintf("%#x", nbnet.PreroutingFwmarkRedirected), "-j", "ACCEPT"}, + position: 2, + }, + } +} + +func (r *family) appendToEntries(chain chainKey, spec ruleSpec) { + r.entries[chain] = append(r.entries[chain], spec) +} + +func (r *family) createDefaultChains() error { + if err := r.iptablesClient.NewChain(tableFilter, chainACLInput); err != nil { + return fmt.Errorf("create %s chain: %w", chainACLInput, err) + } + + for chain, rules := range r.entries { + // mangle FORWARD guard rules are handled separately below + if chain == mangleForwardKey { + continue + } + for _, rule := range rules { + if err := r.iptablesClient.InsertUnique(tableFilter, string(chain), 1, rule...); err != nil { + return fmt.Errorf("insert jump rule into %s: %w", chain, err) + } + } + } + + for chain, entries := range r.optionalEntries { + for _, entry := range entries { + if err := r.iptablesClient.InsertUnique(tableFilter, string(chain), entry.position, entry.spec...); err != nil { + log.Errorf("failed to insert optional entry %v: %v", entry.spec, err) + continue + } + r.entries[chain] = append(r.entries[chain], entry.spec) + } + } + clear(r.optionalEntries) + + // Insert mangle FORWARD guard rules to prevent external DNAT bypass. + for _, rule := range r.entries[mangleForwardKey] { + if err := r.iptablesClient.AppendUnique(tableMangle, chainForward, rule...); err != nil { + log.Errorf("failed to add mangle FORWARD guard rule: %v", err) + } + } + + return nil +} + +func (r *family) cleanUpDefaultForwardRules() error { + var merr *multierror.Error + + // cleanJumpRules removes the OUTPUT jump to NETBIRD-NAT-OUTPUT among + // the others, so the chain below deletes cleanly instead of failing + // with "device or resource busy". + if err := r.cleanJumpRules(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("clean jump rules: %w", err)) + } + + for _, chainInfo := range []struct { + chain string + table string + }{ + {chainRTFwdIn, tableFilter}, + {chainRTFwdOut, tableFilter}, + {chainRTPre, tableMangle}, + {chainRTNAT, tableNat}, + {chainRTRdr, tableNat}, + {chainNATOutput, tableNat}, + {chainRTMSSClamp, tableMangle}, + } { + ok, err := r.iptablesClient.ChainExists(chainInfo.table, chainInfo.chain) + if err != nil { + merr = multierror.Append(merr, fmt.Errorf("check chain %s in table %s: %w", chainInfo.chain, chainInfo.table, err)) + continue + } + if ok { + if err := r.iptablesClient.ClearAndDeleteChain(chainInfo.table, chainInfo.chain); err != nil { + merr = multierror.Append(merr, fmt.Errorf("clear and delete chain %s in table %s: %w", chainInfo.chain, chainInfo.table, err)) + } + } + } + + return nberrors.FormatErrorOrNil(merr) +} + +func (r *family) cleanJumpRules() error { + // locations maps each jump rule to the built-in table and chain it + // was inserted into, plus the netbird chain it targets. + locations := map[firewall.RuleID]struct{ table, chain, target string }{ + jumpNATPost: {tableNat, chainPostrouting, chainRTNAT}, + jumpManglePre: {tableMangle, chainPrerouting, chainRTPre}, + jumpNATPre: {tableNat, chainPrerouting, chainRTRdr}, + jumpMSSClamp: {tableMangle, chainForward, chainRTMSSClamp}, + jumpNATOutput: {tableNat, chainOutput, chainNATOutput}, + } + + var merr *multierror.Error + for ruleID, loc := range locations { + rule, exists := r.rules[ruleID] + if !exists { + // Untracked (e.g. fresh start after an unclean shutdown with no + // restored state): if the target chain survived, remove the stale + // jump to it so the chain can be deleted. + ok, err := r.iptablesClient.ChainExists(loc.table, loc.target) + if err != nil { + merr = multierror.Append(merr, fmt.Errorf("check chain %s in table %s: %w", loc.target, loc.table, err)) + continue + } + if !ok { + continue + } + rule = jumpRuleSpec(loc.target) + } + if err := r.iptablesClient.DeleteIfExists(loc.table, loc.chain, rule...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("delete rule from chain %s in table %s: %w", loc.chain, loc.table, err)) + continue + } + delete(r.rules, ruleID) + } + return nberrors.FormatErrorOrNil(merr) +} + +// jumpRuleSpec builds the iptables rule spec that jumps to target. Create +// and cleanup sites share it so the installed and deleted specs cannot drift. +func jumpRuleSpec(target string) []string { + return []string{"-j", target} +} + +func (r *family) cleanAclChains() error { + var merr *multierror.Error + + if err := r.cleanInputAclChain(); err != nil { + merr = multierror.Append(merr, err) + } + + for _, rule := range r.entries[mangleForwardKey] { + if err := r.iptablesClient.DeleteIfExists(tableMangle, chainForward, rule...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("delete mangle %s guard rule %v: %w", chainForward, rule, err)) + } + } + + return nberrors.FormatErrorOrNil(merr) +} + +func (r *family) cleanInputAclChain() error { + ok, err := r.iptablesClient.ChainExists(tableFilter, chainACLInput) + if err != nil { + return fmt.Errorf("check chain %s: %w", chainACLInput, err) + } + if !ok { + return nil + } + + var merr *multierror.Error + for _, rule := range r.entries[chainInput] { + if err := r.iptablesClient.DeleteIfExists(tableFilter, chainInput, rule...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("delete %s rule %v: %w", chainInput, rule, err)) + } + } + + for _, rule := range r.entries[chainForward] { + if err := r.iptablesClient.DeleteIfExists(tableFilter, chainForward, rule...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("delete %s rule %v: %w", chainForward, rule, err)) + } + } + + if err := r.iptablesClient.ClearAndDeleteChain(tableFilter, chainACLInput); err != nil { + merr = multierror.Append(merr, fmt.Errorf("clear and delete %s chain: %w", chainACLInput, err)) + } + + return nberrors.FormatErrorOrNil(merr) +} + +func (r *family) cleanupDataPlaneMark() error { + var merr *multierror.Error + if preRule, exists := r.rules[markManglePre]; exists { + if err := r.iptablesClient.DeleteIfExists(tableMangle, chainPrerouting, preRule...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove mangle prerouting rule: %w", err)) + } else { + delete(r.rules, markManglePre) + } + } + + if postRule, exists := r.rules[markManglePost]; exists { + if err := r.iptablesClient.DeleteIfExists(tableMangle, chainPostrouting, postRule...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove mangle postrouting rule: %w", err)) + } else { + delete(r.rules, markManglePost) + } + } + + return nberrors.FormatErrorOrNil(merr) +} diff --git a/client/firewall/iptables/dnat_linux.go b/client/firewall/iptables/dnat_linux.go new file mode 100644 index 000000000..eca8386c0 --- /dev/null +++ b/client/firewall/iptables/dnat_linux.go @@ -0,0 +1,302 @@ +//go:build !android + +package iptables + +import ( + "fmt" + "net/netip" + "strconv" + "strings" + + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + + nberrors "github.com/netbirdio/netbird/client/errors" + firewall "github.com/netbirdio/netbird/client/firewall/manager" +) + +func (r *family) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) { + ruleID := rule.ID() + if _, exists := r.rules[ruleID+dnatSuffix]; exists { + return rule, nil + } + + toDestination := rule.TranslatedAddress.String() + switch { + case len(rule.TranslatedPort.Values) == 0: + // no translated port, use original port + case len(rule.TranslatedPort.Values) == 1: + toDestination += fmt.Sprintf(":%d", rule.TranslatedPort.Values[0]) + case rule.TranslatedPort.IsRange && len(rule.TranslatedPort.Values) == 2: + // need the "/originalport" suffix to avoid dnat port randomization + toDestination += fmt.Sprintf(":%d-%d/%d", rule.TranslatedPort.Values[0], rule.TranslatedPort.Values[1], rule.DestinationPort.Values[0]) + default: + return nil, fmt.Errorf("invalid translated port: %v", rule.TranslatedPort) + } + + proto := strings.ToLower(string(rule.Protocol)) + + rules := make(map[firewall.RuleID]ruleInfo, 3) + + // DNAT rule + dnatRule := []string{ + "!", "-i", r.wgIface.Name(), + "-p", proto, + "-j", "DNAT", + "--to-destination", toDestination, + } + dnatRule = append(dnatRule, applyPort("--dport", &rule.DestinationPort)...) + rules[ruleID+dnatSuffix] = ruleInfo{ + table: tableNat, + chain: chainRTRdr, + rule: dnatRule, + } + + // SNAT rule + snatRule := []string{ + "-o", r.wgIface.Name(), + "-p", proto, + "-d", rule.TranslatedAddress.String(), + "-j", "MASQUERADE", + } + snatRule = append(snatRule, applyPort("--dport", &rule.TranslatedPort)...) + rules[ruleID+snatSuffix] = ruleInfo{ + table: tableNat, + chain: chainRTNAT, + rule: snatRule, + } + + // Forward filtering rule, if fwd policy is DROP + forwardRule := []string{ + "-o", r.wgIface.Name(), + "-p", proto, + "-d", rule.TranslatedAddress.String(), + "-j", "ACCEPT", + } + forwardRule = append(forwardRule, applyPort("--dport", &rule.TranslatedPort)...) + rules[ruleID+fwdSuffix] = ruleInfo{ + table: tableFilter, + chain: chainRTFwdOut, + rule: forwardRule, + } + + for key, ruleInfo := range rules { + if err := r.iptablesClient.Append(ruleInfo.table, ruleInfo.chain, ruleInfo.rule...); err != nil { + 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 *family) cleanupFailedDNATAdd(rules map[firewall.RuleID]ruleInfo) { + for key := range rules { + delete(r.rules, key) + } + if err := r.rollbackRules(rules); err != nil { + log.Errorf("rollback failed: %v", err) + } +} + +func (r *family) rollbackRules(rules map[firewall.RuleID]ruleInfo) error { + var merr *multierror.Error + for key, ruleInfo := range rules { + if err := r.iptablesClient.DeleteIfExists(ruleInfo.table, ruleInfo.chain, ruleInfo.rule...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("rollback rule %s: %w", key, err)) + // On rollback error, add to rules map for next cleanup + r.rules[key] = ruleInfo.rule + } + } + if merr != nil { + r.updateState() + } + return nberrors.FormatErrorOrNil(merr) +} + +func (r *family) DeleteDNATRule(rule firewall.Rule) error { + ruleID := rule.ID() + + _, hadDNAT := r.rules[ruleID+dnatSuffix] + _, hadSNAT := r.rules[ruleID+snatSuffix] + _, hadFWD := r.rules[ruleID+fwdSuffix] + if !hadDNAT && !hadSNAT && !hadFWD { + return nil + } + + var merr *multierror.Error + if dnatRule, exists := r.rules[ruleID+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, ruleID+dnatSuffix) + } + } + + if snatRule, exists := r.rules[ruleID+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, ruleID+snatSuffix) + } + } + + if fwdRule, exists := r.rules[ruleID+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, ruleID+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 { + r.releaseForwarding() + } + + r.updateState() + + return nberrors.FormatErrorOrNil(merr) +} + +// releaseForwarding drops one IP forwarding reference, logging any error. +func (r *family) releaseForwarding() { + if err := r.ipFwdState.ReleaseForwarding(r.v6); err != nil { + log.Errorf("release IP forwarding: %v", err) + } +} + +func (r *family) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { + ruleID := firewall.RuleID(fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort)) + + if _, exists := r.rules[ruleID]; exists { + return nil + } + + dnatRule := []string{ + "-i", r.wgIface.Name(), + "-p", strings.ToLower(protoForFamily(protocol, r.v6)), + "--dport", strconv.Itoa(int(originalPort)), + "-d", localAddr.String(), + "-m", "addrtype", "--dst-type", "LOCAL", + "-j", "DNAT", + "--to-destination", ":" + strconv.Itoa(int(translatedPort)), + } + + info := ruleInfo{ + table: tableNat, + chain: chainRTRdr, + rule: dnatRule, + } + + if err := r.iptablesClient.Append(info.table, info.chain, info.rule...); err != nil { + return fmt.Errorf("add inbound DNAT rule: %w", err) + } + r.rules[ruleID] = info.rule + + r.updateState() + return nil +} + +// RemoveInboundDNAT removes an inbound DNAT rule. +func (r *family) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { + ruleID := firewall.RuleID(fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort)) + + if dnatRule, exists := r.rules[ruleID]; exists { + if err := r.iptablesClient.Delete(tableNat, chainRTRdr, dnatRule...); err != nil { + return fmt.Errorf("delete inbound DNAT rule: %w", err) + } + delete(r.rules, ruleID) + } + + r.updateState() + return nil +} + +// ensureNATOutputChain lazily creates the OUTPUT NAT chain and jump rule on first use. +func (r *family) ensureNATOutputChain() error { + if _, exists := r.rules[jumpNATOutput]; exists { + return nil + } + + chainExists, err := r.iptablesClient.ChainExists(tableNat, chainNATOutput) + if err != nil { + return fmt.Errorf("check chain %s: %w", chainNATOutput, err) + } + if !chainExists { + if err := r.iptablesClient.NewChain(tableNat, chainNATOutput); err != nil { + return fmt.Errorf("create chain %s: %w", chainNATOutput, err) + } + } + + jumpRule := jumpRuleSpec(chainNATOutput) + if err := r.iptablesClient.Insert(tableNat, chainOutput, 1, jumpRule...); err != nil { + if !chainExists { + if delErr := r.iptablesClient.ClearAndDeleteChain(tableNat, chainNATOutput); delErr != nil { + log.Warnf("failed to rollback chain %s: %v", chainNATOutput, delErr) + } + } + return fmt.Errorf("add OUTPUT jump rule: %w", err) + } + r.rules[jumpNATOutput] = jumpRule + + r.updateState() + return nil +} + +// AddOutputDNAT adds an OUTPUT chain DNAT rule for locally-generated traffic. +func (r *family) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { + ruleID := firewall.RuleID(fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort)) + + if _, exists := r.rules[ruleID]; exists { + return nil + } + + if err := r.ensureNATOutputChain(); err != nil { + return err + } + + dnatRule := []string{ + "-p", strings.ToLower(protoForFamily(protocol, localAddr.Is6())), + "--dport", strconv.Itoa(int(originalPort)), + "-d", localAddr.String(), + "-j", "DNAT", + "--to-destination", ":" + strconv.Itoa(int(translatedPort)), + } + + if err := r.iptablesClient.Append(tableNat, chainNATOutput, dnatRule...); err != nil { + return fmt.Errorf("add output DNAT rule: %w", err) + } + r.rules[ruleID] = dnatRule + + r.updateState() + return nil +} + +// RemoveOutputDNAT removes an OUTPUT chain DNAT rule. +func (r *family) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { + ruleID := firewall.RuleID(fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort)) + + if dnatRule, exists := r.rules[ruleID]; exists { + if err := r.iptablesClient.Delete(tableNat, chainNATOutput, dnatRule...); err != nil { + return fmt.Errorf("delete output DNAT rule: %w", err) + } + delete(r.rules, ruleID) + } + + r.updateState() + return nil +} diff --git a/client/firewall/iptables/dnat_refcount_linux_test.go b/client/firewall/iptables/dnat_refcount_linux_test.go new file mode 100644 index 000000000..40ebc6cc3 --- /dev/null +++ b/client/firewall/iptables/dnat_refcount_linux_test.go @@ -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.family4.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.family4.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.family4.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.family6, "v6 family") + require.Same(t, m.family4.ipFwdState, m.family6.ipFwdState, "shared state") + state := m.family4.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.family4.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.family4.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.family4.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") +} diff --git a/client/firewall/iptables/family_linux.go b/client/firewall/iptables/family_linux.go new file mode 100644 index 000000000..c5ed8cc20 --- /dev/null +++ b/client/firewall/iptables/family_linux.go @@ -0,0 +1,258 @@ +//go:build !android + +package iptables + +import ( + "fmt" + "maps" + "net/netip" + + "github.com/coreos/go-iptables/iptables" + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + + nberrors "github.com/netbirdio/netbird/client/errors" + firewall "github.com/netbirdio/netbird/client/firewall/manager" + nbid "github.com/netbirdio/netbird/client/internal/acl/id" + "github.com/netbirdio/netbird/client/internal/routemanager/ipfwdstate" + "github.com/netbirdio/netbird/client/internal/routemanager/refcounter" + "github.com/netbirdio/netbird/client/internal/statemanager" +) + +// constants needed to manage and create iptable rules +const ( + tableFilter = "filter" + tableNat = "nat" + tableMangle = "mangle" + + // chainACLInput is the peer ACL chain that holds installed + // peer-filtering rules. + chainACLInput = "NETBIRD-ACL-INPUT" + + // mangleForwardKey is the entries map key for mangle FORWARD guard + // rules that prevent external DNAT from bypassing ACL rules. + mangleForwardKey chainKey = "MANGLE-FORWARD" + + chainInput = "INPUT" + chainPostrouting = "POSTROUTING" + chainPrerouting = "PREROUTING" + chainForward = "FORWARD" + chainRTNAT = "NETBIRD-RT-NAT" + chainRTFwdIn = "NETBIRD-RT-FWD-IN" + chainRTFwdOut = "NETBIRD-RT-FWD-OUT" + chainRTPre = "NETBIRD-RT-PRE" + chainRTRdr = "NETBIRD-RT-RDR" + chainNATOutput = "NETBIRD-NAT-OUTPUT" + chainRTMSSClamp = "NETBIRD-RT-MSSCLAMP" + + jumpManglePre = "jump-mangle-pre" + jumpNATPre = "jump-nat-pre" + jumpNATPost = "jump-nat-post" + jumpNATOutput = "jump-nat-output" + jumpMSSClamp = "jump-mss-clamp" + markManglePre = "mark-mangle-pre" + markManglePost = "mark-mangle-post" + matchSet = "--match-set" + + dnatSuffix firewall.RuleID = "_dnat" + snatSuffix firewall.RuleID = "_snat" + fwdSuffix firewall.RuleID = "_fwd" + + // ipv4TCPHeaderSize is the minimum IPv4 (20) + TCP (20) header size for MSS calculation. + ipv4TCPHeaderSize = 40 + // ipv6TCPHeaderSize is the minimum IPv6 (40) + TCP (20) header size for MSS calculation. + ipv6TCPHeaderSize = 60 +) + +type ruleInfo struct { + chain string + table string + rule []string +} + +type routeRules map[firewall.RuleID][]string + +// ruleSpec is a single iptables rule expressed as its argument list +// (e.g. {"-i", "wg0", "-j", "DROP"}). +type ruleSpec []string + +// chainKey identifies the chain a seeded entry belongs to. It holds +// built-in chain names ("INPUT", "FORWARD", "PREROUTING") plus the +// synthetic mangleForwardKey bucket for the mangle FORWARD guard rules. +type chainKey string + +// aclEntries maps a chain to the rules seeded into it to jump into or +// guard the netbird ACL chains. +type aclEntries map[chainKey][]ruleSpec + +type entry struct { + spec ruleSpec + position int +} + +// ipsetCounter is the shared hash:net refcounter used by peer and +// route ACLs alike. The ipset library does not support comments, so +// the key is just the set name (string). +type ipsetCounter = refcounter.Counter[string, []netip.Prefix, struct{}] + +// family holds the per-address-family iptables state. One instance +// handles route ACLs, peer ACLs, NAT, DNAT, and MSS clamping for a +// single family; the top-level Manager owns one for v4 and another +// for v6. +type family struct { + iptablesClient *iptables.IPTables + wgIface iFaceMapper + v6 bool + + // Peer ACL chain bookkeeping. + entries aclEntries + optionalEntries map[chainKey][]entry + + // filters holds peer + route filter rules keyed by content hash. + // AddFilterRule writes here; DeleteFilterRule looks up by id. + filters map[nbid.RuleID]*Rule + ipsetCounter *ipsetCounter + // ipsetSupported records whether the kernel can create the hash:net + // sets the source matches rely on; probed once at init. When false, + // multi-source rules expand to one rule per source prefix. + ipsetSupported bool + + // rules holds NAT, jump, and MSS-clamping rules (auxiliary + // plumbing that isn't a filter rule). + rules routeRules + + // Routing / NAT. + legacyManagement bool + mtu uint16 + ipFwdState *ipfwdstate.IPForwardingState + + stateManager *statemanager.Manager +} + +func newFamily(iptablesClient *iptables.IPTables, wgIface iFaceMapper, mtu uint16) (*family, error) { + r := &family{ + iptablesClient: iptablesClient, + wgIface: wgIface, + v6: iptablesClient.Proto() == iptables.ProtocolIPv6, + entries: make(aclEntries), + optionalEntries: make(map[chainKey][]entry), + filters: make(map[nbid.RuleID]*Rule), + rules: make(routeRules), + mtu: mtu, + ipFwdState: ipfwdstate.NewIPForwardingState(wgIface.Name()), + } + + r.ipsetCounter = refcounter.New( + func(name string, sources []netip.Prefix) (struct{}, error) { + return struct{}{}, r.createIpSet(name, sources) + }, + func(name string, _ struct{}) error { + return r.deleteIpSet(name) + }, + ) + + return r, nil +} + +// init wires the family to the state manager and installs both the +// route ACL containers and the peer ACL chain skeleton. +func (r *family) init(stateManager *statemanager.Manager) error { + r.stateManager = stateManager + + r.ipsetSupported = r.probeIPSetSupport() + + if err := r.cleanUpDefaultForwardRules(); err != nil { + log.Errorf("failed to clean up rules from FORWARD chain: %s", err) + } + + if err := r.createContainers(); err != nil { + return fmt.Errorf("create containers: %w", err) + } + + if err := r.setupDataPlaneMark(); err != nil { + log.Errorf("failed to set up data plane mark: %v", err) + } + + r.seedInitialEntries() + r.seedInitialOptionalEntries() + + if err := r.cleanAclChains(); err != nil { + return fmt.Errorf("clean acl chains: %w", err) + } + if err := r.createDefaultChains(); err != nil { + return fmt.Errorf("create default chains: %w", err) + } + + r.updateState() + + return nil +} + +// Reset tears down all firewall state owned by this family. ACL +// chain cleanup runs before route-chain cleanup because the route +// chains are still referenced by FORWARD jumps installed during +// seedInitialEntries; deleting them first would trip EBUSY. +func (r *family) Reset() error { + var merr *multierror.Error + + if err := r.cleanAclChains(); err != nil { + merr = multierror.Append(merr, err) + } + + if err := r.cleanUpDefaultForwardRules(); err != nil { + merr = multierror.Append(merr, err) + } + + if err := r.ipsetCounter.Flush(); err != nil { + merr = multierror.Append(merr, err) + } + + if err := r.cleanupDataPlaneMark(); err != nil { + merr = multierror.Append(merr, err) + } + + clear(r.rules) + clear(r.filters) + r.updateState() + + return nberrors.FormatErrorOrNil(merr) +} + +func (r *family) updateState() { + if r.stateManager == nil { + return + } + + var currentState *ShutdownState + if existing := r.stateManager.GetState(currentState); existing != nil { + if existingState, ok := existing.(*ShutdownState); ok { + currentState = existingState + } + } + if currentState == nil { + currentState = &ShutdownState{} + } + + currentState.Lock() + defer currentState.Unlock() + + // Clone the rule maps so the persisted state holds a private snapshot. + // The live maps keep being mutated by subsequent rule operations while + // the state manager marshals the state from its periodic-save goroutine. + // Sharing the maps by reference races the two and aborts the process with + // a concurrent map iteration and write. The ipset counter guards itself + // during marshaling, so it can be shared directly. + if r.v6 { + currentState.RouteRules6 = maps.Clone(r.rules) + currentState.RouteIPsetCounter6 = r.ipsetCounter + currentState.ACLEntries6 = maps.Clone(r.entries) + } else { + currentState.RouteRules = maps.Clone(r.rules) + currentState.RouteIPsetCounter = r.ipsetCounter + currentState.ACLEntries = maps.Clone(r.entries) + } + + if err := r.stateManager.UpdateState(currentState); err != nil { + log.Errorf("failed to update state: %v", err) + } +} diff --git a/client/firewall/iptables/filter_linux.go b/client/firewall/iptables/filter_linux.go new file mode 100644 index 000000000..dc606da2d --- /dev/null +++ b/client/firewall/iptables/filter_linux.go @@ -0,0 +1,430 @@ +//go:build !android + +package iptables + +import ( + "fmt" + "net/netip" + "slices" + "strconv" + "strings" + + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + + nberrors "github.com/netbirdio/netbird/client/errors" + firewall "github.com/netbirdio/netbird/client/firewall/manager" + nbid "github.com/netbirdio/netbird/client/internal/acl/id" + nbnet "github.com/netbirdio/netbird/client/net" +) + +// AddFilterRule installs a packet-filtering rule. With destination +// empty, the rule goes to the peer ACL input chain plus a paired +// mangle PREROUTING rule for the redirect mark. With destination set +// (prefix or named set), it goes to the route ACL forward chain. +// Multi-source rules collapse to one iptables rule via the shared +// hash:net ipset. +func (r *family) AddFilterRule( + id []byte, + sources []netip.Prefix, + destination firewall.Network, + proto firewall.Protocol, + sPort *firewall.Port, + dPort *firewall.Port, + action firewall.Action, +) (firewall.Rule, error) { + ruleID := nbid.GenerateRuleID(sources, destination, proto, sPort, dPort, action) + if existing, ok := r.filters[ruleID]; ok { + return existing, nil + } + + rule, err := r.installFilterRules(ruleID, sources, destination, proto, sPort, dPort, action, r.ipsetSupported) + if err != nil { + return nil, err + } + + r.filters[ruleID] = rule + r.updateState() + return rule, nil +} + +// installFilterRules resolves the source matches and installs one +// iptables rule per match. It is more than one rule only when useIPSet +// is false and a multi-source rule has to be expanded per prefix. +func (r *family) installFilterRules( + ruleID nbid.RuleID, + sources []netip.Prefix, + destination firewall.Network, + proto firewall.Protocol, + sPort *firewall.Port, + dPort *firewall.Port, + action firewall.Action, + useIPSet bool, +) (*Rule, error) { + srcMatches, err := r.applySourceMatches(sources, useIPSet) + if err != nil { + return nil, fmt.Errorf("apply source match: %w", err) + } + + rule, err := r.installFilterRule(ruleID, srcMatches, destination, proto, sPort, dPort, action) + if err != nil { + for _, srcMatch := range srcMatches { + r.dropSourceMatch(srcMatch) + } + return nil, err + } + return rule, nil +} + +func (r *family) hasRule(id nbid.RuleID) bool { + _, ok := r.filters[id] + return ok +} + +// hasDNATRule reports whether this family owns the DNAT rule set for +// the given user id. DNAT rules live in r.rules under the well-known +// "_dnat" key; the lookup here is used by Manager.DeleteDNATRule +// to pick the right family. +func (r *family) hasDNATRule(id firewall.RuleID) bool { + _, ok := r.rules[id+dnatSuffix] + return ok +} + +// DeleteFilterRule removes a previously installed filter rule. The +// rule's stored chain/table identify where to delete from; source set +// references are recovered from the spec via findSets and dropped +// from the shared ipset counter. +func (r *family) DeleteFilterRule(rule firewall.Rule) error { + ruleID := rule.ID() + pr, ok := r.filters[ruleID] + if !ok { + log.Debugf("filter rule %s not found", ruleID) + return nil + } + + // DeleteIfExists keeps the deletes idempotent so a retry after a + // partial failure does not error on the parts already removed. + var merr *multierror.Error + for _, fs := range pr.allSpecs() { + if err := r.iptablesClient.DeleteIfExists(tableFilter, pr.chain, fs.specs...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("delete rule from %s: %w", pr.chain, err)) + } + if fs.mangleSpecs != nil { + if err := r.iptablesClient.DeleteIfExists(tableMangle, chainRTPre, fs.mangleSpecs...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("delete mangle rule: %w", err)) + } + } + } + if merr != nil { + // Leave the rule tracked so the caller retries the remaining part. + return nberrors.FormatErrorOrNil(merr) + } + + // The rule is gone from iptables, so untrack it regardless of how the + // refcount decrement goes, but surface decrement failures so callers + // see the ipset desync. Only the primary spec can reference sets: the + // per-prefix expansion never uses them. + delete(r.filters, ruleID) + r.updateState() + if err := r.decrementSetCounter(pr.specs); err != nil { + return fmt.Errorf("drop source set references: %w", err) + } + return nil +} + +// findSets scans an iptables rule spec for "-m set --match-set +// " fragments and returns the named sets in occurrence order. +// Used at delete time to drop ipsetCounter references. +func findSets(rule []string) []string { + var sets []string + for i, arg := range rule { + if arg == "-m" && i+3 < len(rule) && rule[i+1] == "set" && rule[i+2] == matchSet { + sets = append(sets, rule[i+3]) + } + } + return sets +} + +// sourceNetwork classifies a source-prefix list into the firewall.Network +// shape the rest of the spec-builder consumes: empty for match-any, a +// single prefix inline, or an ipset for multiple sources. +func sourceNetwork(sources []netip.Prefix) firewall.Network { + switch { + case len(sources) == 0: + return firewall.Network{} + case len(sources) == 1 && sources[0].Bits() == 0: + return firewall.Network{} + case len(sources) == 1: + return firewall.Network{Prefix: sources[0]} + default: + return firewall.Network{Set: firewall.NewPrefixSet(sources)} + } +} + +// applySourceMatches returns one source match fragment per iptables +// rule needed for the sources: normally a single fragment (a set match, +// a direct -s match, or nil for match-any), and one -s fragment per +// prefix when a multi-source rule cannot use ipset. Per-prefix rules +// are the only form a kernel without the ipset modules can express. +func (r *family) applySourceMatches(sources []netip.Prefix, useIPSet bool) ([][]string, error) { + network := sourceNetwork(sources) + if !network.IsSet() || useIPSet { + match, err := r.applySourceMatch(network, sources) + if err != nil { + return nil, err + } + return [][]string{match}, nil + } + + matches := make([][]string, 0, len(sources)) + for _, source := range sources { + matches = append(matches, []string{"-s", source.String()}) + } + return matches, nil +} + +// applySourceMatch returns the iptables match fragment for the rule's +// source. For a Set it increments the shared ipset's refcount; for a +// Prefix it emits a direct -s match; for the wildcard it returns nil. +func (r *family) applySourceMatch(network firewall.Network, prefixes []netip.Prefix) ([]string, error) { + switch { + case network.IsSet(): + if r.ipsetCounter == nil { + return nil, fmt.Errorf("multi-source peer rule requires shared ipset counter") + } + name := r.ipsetName(network.Set.HashedName()) + if _, err := r.ipsetCounter.Increment(name, prefixes); err != nil { + return nil, fmt.Errorf("ipset increment %s: %w", name, err) + } + return []string{"-m", "set", matchSet, name, "src"}, nil + case network.IsPrefix(): + return []string{"-s", network.Prefix.String()}, nil + default: + return nil, nil + } +} + +// dropSourceMatch undoes whatever applySourceMatch reserved when +// installing a rule fails. Safe to call when the spec is empty or holds +// only inline matchers. Decrement errors are logged but not returned: +// the install error is what the caller needs to see. +func (r *family) dropSourceMatch(srcMatch []string) { + if r.ipsetCounter == nil { + return + } + for _, name := range findSets(srcMatch) { + if _, err := r.ipsetCounter.Decrement(name); err != nil { + log.Errorf("rollback ipset decrement %s: %v", name, err) + } + } +} + +// decrementSetCounter drops ipset references owned by a raw rule spec +// stored in r.rules (NAT / legacy route entries). It returns an error +// aggregate so the caller surfaces decrement failures. +func (r *family) decrementSetCounter(rule []string) error { + if r.ipsetCounter == nil { + return nil + } + var merr *multierror.Error + for _, name := range findSets(rule) { + if _, err := r.ipsetCounter.Decrement(name); err != nil { + merr = multierror.Append(merr, fmt.Errorf("decrement counter: %w", err)) + } + } + return nberrors.FormatErrorOrNil(merr) +} + +// installFilterRule assembles and writes the iptables filter-chain +// rules for one filter rule, one per source match fragment. With +// destination empty the rules land in the peer ACL input chain and each +// gets a paired mangle PREROUTING rule for the redirect mark. With +// destination set the rules land in the route ACL forward chain and +// there is no mangle pairing. +func (r *family) installFilterRule( + ruleID nbid.RuleID, + srcMatches [][]string, + destination firewall.Network, + protocol firewall.Protocol, + sPort, dPort *firewall.Port, + action firewall.Action, +) (*Rule, error) { + isRoute := !destination.IsZero() + + proto := protoForFamily(protocol, r.v6) + + var destExp []string + if isRoute { + var err error + destExp, err = r.applyNetwork("-d", destination, nil) + if err != nil { + return nil, fmt.Errorf("apply network -d: %w", err) + } + } + matchSpecs := filterMatchSpecs(proto, sPort, dPort) + + chain := chainACLInput + if isRoute { + chain = chainRTFwdIn + } + + var installed []filterSpecs + for _, srcMatch := range srcMatches { + specs := slices.Clone(srcMatch) + specs = append(specs, destExp...) + specs = append(specs, matchSpecs...) + + var mangleSpecs []string + if !isRoute { + mangleSpecs = slices.Clone(specs) + mangleSpecs = append(mangleSpecs, + "-i", r.wgIface.Name(), + "-m", "addrtype", "--dst-type", "LOCAL", + "-j", "MARK", "--set-xmark", fmt.Sprintf("%#x", nbnet.PreroutingFwmarkRedirected), + ) + } + + specs = append(specs, "-j", actionToStr(action)) + + if err := r.insertFilterRule(chain, action, specs); err != nil { + // Leave nothing half-installed: the caller sees an error, so a + // partial rule would silently keep matching without being tracked. + r.removeFilterSpecs(chain, installed) + r.dropSourceMatch(destExp) + return nil, fmt.Errorf("install filter rule on %s: %w", chain, err) + } + + // The mangle redirect-mark rule is best effort: the filter rule itself + // is what enforces the ACL, so a mangle failure must not undo it. Drop + // the spec so teardown does not try to remove a rule that was not added. + if mangleSpecs != nil { + if err := r.iptablesClient.Append(tableMangle, chainRTPre, mangleSpecs...); err != nil { + log.Errorf("add mangle rule: %v", err) + mangleSpecs = nil + } + } + + installed = append(installed, filterSpecs{specs: specs, mangleSpecs: mangleSpecs}) + } + + return &Rule{ + id: ruleID, + specs: installed[0].specs, + mangleSpecs: installed[0].mangleSpecs, + extraRules: installed[1:], + chain: chain, + v6: r.v6, + }, nil +} + +// insertFilterRule writes one assembled rule spec into the given ACL +// chain. Peer ACL drops are inserted at position 1 so they precede the +// chain's catch-all; route ACL drops are inserted at position 2 to sit +// immediately after the established/related accept rule. +func (r *family) insertFilterRule(chain string, action firewall.Action, specs []string) error { + if action == firewall.ActionDrop { + pos := 1 + if chain == chainRTFwdIn { + pos = 2 + } + return r.iptablesClient.Insert(tableFilter, chain, pos, specs...) + } + return r.iptablesClient.Append(tableFilter, chain, specs...) +} + +// removeFilterSpecs deletes the already-installed rules of a partially +// applied filter rule. +func (r *family) removeFilterSpecs(chain string, installed []filterSpecs) { + for _, fs := range installed { + if err := r.iptablesClient.DeleteIfExists(tableFilter, chain, fs.specs...); err != nil { + log.Debugf("delete partial filter rule: %v", err) + } + if fs.mangleSpecs != nil { + if err := r.iptablesClient.DeleteIfExists(tableMangle, chainRTPre, fs.mangleSpecs...); err != nil { + log.Debugf("delete partial mangle rule: %v", err) + } + } + } +} + +// applyNetwork resolves a firewall.Network into the iptables match +// fragment for the given direction flag (-s or -d). Set networks +// increment the shared ipset refcount; prefixes emit a direct match; +// an empty network returns no spec ("match any"). +func (r *family) applyNetwork(flag string, network firewall.Network, prefixes []netip.Prefix) ([]string, error) { + direction := "src" + if flag == "-d" { + direction = "dst" + } + + if network.IsSet() { + // A destination set is populated later from DNS results, so unlike a + // source set it cannot be expanded into per-prefix rules. Without + // ipset such a rule is not expressible; report it instead of + // installing something broader than the policy allows. + if flag == "-d" && !r.ipsetSupported { + return nil, fmt.Errorf("destination set %s requires ipset (ip_set_hash_net and xt_set)", network.Set.HashedName()) + } + + name := r.ipsetName(network.Set.HashedName()) + if _, err := r.ipsetCounter.Increment(name, prefixes); err != nil { + return nil, fmt.Errorf("create or get ipset: %w", err) + } + + return []string{"-m", "set", matchSet, name, direction}, nil + } + if network.IsPrefix() { + return []string{flag, network.Prefix.String()}, nil + } + + // nolint:nilnil + return nil, nil +} + +// protoForFamily translates ICMP to ICMPv6 for ip6tables. +// ip6tables requires "ipv6-icmp" (or "icmpv6") instead of "icmp". +func protoForFamily(protocol firewall.Protocol, v6 bool) string { + if v6 && protocol == firewall.ProtocolICMP { + return "ipv6-icmp" + } + return string(protocol) +} + +// filterMatchSpecs returns the proto/port match fragment for a +// filtering rule. The source match (-s or -m set) is built by the +// caller and prepended. +func filterMatchSpecs(protocol string, sPort, dPort *firewall.Port) (specs []string) { + if protocol != "all" { + specs = append(specs, "-p", protocol) + } + specs = append(specs, applyPort("--sport", sPort)...) + specs = append(specs, applyPort("--dport", dPort)...) + return specs +} + +func actionToStr(action firewall.Action) string { + if action == firewall.ActionAccept { + return "ACCEPT" + } + return "DROP" +} + +func applyPort(flag string, port *firewall.Port) []string { + if port == nil { + return nil + } + + if port.IsRange && len(port.Values) == 2 { + return []string{flag, fmt.Sprintf("%d:%d", port.Values[0], port.Values[1])} + } + + if len(port.Values) > 1 { + portList := make([]string, len(port.Values)) + for i, p := range port.Values { + portList[i] = strconv.Itoa(int(p)) + } + return []string{"-m", "multiport", flag, strings.Join(portList, ",")} + } + + return []string{flag, strconv.Itoa(int(port.Values[0]))} +} diff --git a/client/firewall/iptables/interface_allower_linux.go b/client/firewall/iptables/interface_allower_linux.go new file mode 100644 index 000000000..40e9728e2 --- /dev/null +++ b/client/firewall/iptables/interface_allower_linux.go @@ -0,0 +1,93 @@ +package iptables + +import ( + "fmt" + + "github.com/coreos/go-iptables/iptables" + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + + nberrors "github.com/netbirdio/netbird/client/errors" +) + +// InterfaceAllower opens the NetBird interface on the iptables filter INPUT +// chain so the host firewall doesn't drop traffic the userspace firewall +// handles. It is the fallback used when nftables is unavailable (an +// iptables-legacy host). +// +// It opens INPUT only: the userspace router never forwards in the kernel. +// firewalld trust is handled by the uspfilter manager, not here. +type InterfaceAllower struct { + ifaceName string + ipt4 *iptables.IPTables + // ipt6 is nil when the interface has no IPv6 overlay address. + ipt6 *iptables.IPTables +} + +// NewInterfaceAllower builds an iptables allower for the interface. It returns +// an error when iptables is unavailable, so the caller can fall back to +// firewalld trust. +func NewInterfaceAllower(wgIface iFaceMapper) (*InterfaceAllower, error) { + ipt4, err := iptables.NewWithProtocol(iptables.ProtocolIPv4) + if err != nil { + return nil, fmt.Errorf("iptables not available: %w", err) + } + if _, err := ipt4.ListChains(tableFilter); err != nil { + return nil, fmt.Errorf("iptables filter table not available: %w", err) + } + + a := &InterfaceAllower{ifaceName: wgIface.Name(), ipt4: ipt4} + + // Missing v6 must not break the v4 path: open v4 only and continue. + if wgIface.Address().HasIPv6() { + ipt6, err := iptables.NewWithProtocol(iptables.ProtocolIPv6) + if err != nil { + log.Warnf("ip6tables not available, opening interface on v4 only: %v", err) + } else if _, err := ipt6.ListChains(tableFilter); err != nil { + log.Warnf("ip6tables filter table not available, opening interface on v4 only: %v", err) + } else { + a.ipt6 = ipt6 + } + } + + return a, nil +} + +// Apply inserts the interface accept rule on the filter INPUT chain. It removes +// any stale rule first so an unclean exit (e.g. SIGKILL, where Close never ran) +// is recovered deterministically rather than accumulating duplicates. +func (a *InterfaceAllower) Apply() error { + var merr *multierror.Error + for _, ipt := range a.clients() { + if err := ipt.DeleteIfExists(tableFilter, chainInput, a.inputRule()...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("clean stale interface accept rule: %w", err)) + } + if err := ipt.Insert(tableFilter, chainInput, 1, a.inputRule()...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("add interface accept rule: %w", err)) + } + } + return nberrors.FormatErrorOrNil(merr) +} + +// Close removes the interface accept rule. +func (a *InterfaceAllower) Close() error { + var merr *multierror.Error + for _, ipt := range a.clients() { + if err := ipt.DeleteIfExists(tableFilter, chainInput, a.inputRule()...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove interface accept rule: %w", err)) + } + } + return nberrors.FormatErrorOrNil(merr) +} + +func (a *InterfaceAllower) inputRule() []string { + return []string{"-i", a.ifaceName, "-j", "ACCEPT"} +} + +func (a *InterfaceAllower) clients() []*iptables.IPTables { + clients := []*iptables.IPTables{a.ipt4} + if a.ipt6 != nil { + clients = append(clients, a.ipt6) + } + return clients +} diff --git a/client/firewall/iptables/ipset_linux.go b/client/firewall/iptables/ipset_linux.go new file mode 100644 index 000000000..2a3685af7 --- /dev/null +++ b/client/firewall/iptables/ipset_linux.go @@ -0,0 +1,131 @@ +//go:build !android + +package iptables + +import ( + "fmt" + "net/netip" + + "github.com/google/uuid" + "github.com/hashicorp/go-multierror" + "github.com/lrh3321/ipset-go" + log "github.com/sirupsen/logrus" + + nberrors "github.com/netbirdio/netbird/client/errors" + firewall "github.com/netbirdio/netbird/client/firewall/manager" +) + +// probeIPSetSupport checks whether the kernel can create the ipset type +// used for source and destination matches. On kernels lacking the +// required ipset hash module, set creation fails (e.g. "invalid +// argument"), which would otherwise fail every multi-source rule and +// leave traffic the policy permits blocked by the catch-all drop. When +// unsupported, multi-source rules fall back to one rule per prefix. +func (r *family) 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] + + if err := r.createIPSet(probeName); 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 + } + + if err := r.destroyIPSet(probeName); err != nil { + log.Debugf("destroy ipset probe set %q: %v", probeName, err) + } + + return true +} + +func (r *family) createIpSet(setName string, sources []netip.Prefix) error { + if err := r.createIPSet(setName); err != nil { + return fmt.Errorf("create set %s: %w", setName, err) + } + + for _, prefix := range sources { + if err := r.addPrefixToIPSet(setName, prefix); err != nil { + // The refcounter records nothing when this callback errors, + // so destroy the set or it leaks in the kernel. A partial + // source set would also fail-open for deny rules, so the + // rule must fail rather than install with a missing source. + if derr := r.destroyIPSet(setName); derr != nil { + log.Warnf("rollback ipset %s after add failure: %v", setName, derr) + } + return fmt.Errorf("add element to set %s: %w", setName, err) + } + } + + return nil +} + +func (r *family) deleteIpSet(setName string) error { + if err := r.destroyIPSet(setName); err != nil { + return fmt.Errorf("destroy set %s: %w", setName, err) + } + + log.Debugf("deleted unused ipset %s", setName) + return nil +} + +func (r *family) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error { + name := r.ipsetName(set.HashedName()) + var merr *multierror.Error + for _, prefix := range prefixes { + if err := r.addPrefixToIPSet(name, prefix); err != nil { + merr = multierror.Append(merr, fmt.Errorf("add prefix to ipset: %w", err)) + } + } + if merr == nil { + log.Debugf("updated set %s with prefixes %v", name, prefixes) + } + + return nberrors.FormatErrorOrNil(merr) +} + +func (r *family) ipsetName(name string) string { + if r.v6 { + return name + "-v6" + } + return name +} + +func (r *family) createIPSet(name string) error { + opts := ipset.CreateOptions{ + Replace: true, + } + if r.v6 { + opts.Family = ipset.FamilyIPV6 + } + + if err := ipset.Create(name, ipset.TypeHashNet, opts); err != nil { + return fmt.Errorf("create ipset %s: %w", name, err) + } + + log.Debugf("created ipset %s with type hash:net", name) + return nil +} + +func (r *family) addPrefixToIPSet(name string, prefix netip.Prefix) error { + addr := prefix.Addr() + ip := addr.AsSlice() + + entry := &ipset.Entry{ + IP: ip, + CIDR: uint8(prefix.Bits()), + Replace: true, + } + + if err := ipset.Add(name, entry); err != nil { + return fmt.Errorf("add prefix to ipset %s: %w", name, err) + } + + return nil +} + +func (r *family) destroyIPSet(name string) error { + return ipset.Destroy(name) +} diff --git a/client/firewall/iptables/manager_linux.go b/client/firewall/iptables/manager_linux.go index 696537dd8..49b88f1ea 100644 --- a/client/firewall/iptables/manager_linux.go +++ b/client/firewall/iptables/manager_linux.go @@ -3,7 +3,6 @@ package iptables import ( "context" "fmt" - "net" "net/netip" "sync" @@ -18,25 +17,21 @@ import ( "github.com/netbirdio/netbird/client/internal/statemanager" ) -type resetter interface { - Reset() error -} - -// Manager of iptables firewall +// Manager of iptables firewall. Per-family state (peer ACLs, route +// ACLs, NAT, DNAT, MSS clamping) lives on family; Manager dispatches +// by family and provides the public firewall.Manager surface. type Manager struct { mutex sync.Mutex wgIface iFaceMapper ipv4Client *iptables.IPTables - aclMgr *aclManager - router *router + family4 *family rawSupported bool // IPv6 counterparts, nil when no v6 overlay ipv6Client *iptables.IPTables - aclMgr6 *aclManager - router6 *router + family6 *family } // iFaceMapper defines subset methods of interface required for manager @@ -57,14 +52,9 @@ func Create(wgIface iFaceMapper, mtu uint16) (*Manager, error) { ipv4Client: iptablesClient, } - m.router, err = newRouter(iptablesClient, wgIface, mtu) + m.family4, err = newFamily(iptablesClient, wgIface, mtu) if err != nil { - return nil, fmt.Errorf("create router: %w", err) - } - - m.aclMgr, err = newAclManager(iptablesClient, wgIface) - if err != nil { - return nil, fmt.Errorf("create acl manager: %w", err) + return nil, fmt.Errorf("create family: %w", err) } if wgIface.Address().HasIPv6() { @@ -81,21 +71,18 @@ func (m *Manager) createIPv6Components(wgIface iFaceMapper, mtu uint16) error { if err != nil { return fmt.Errorf("init ip6tables: %w", err) } + + family6, err := newFamily(ip6Client, wgIface, mtu) + if err != nil { + return fmt.Errorf("create v6 family: %w", err) + } + + // Share the same IP forwarding state with the v4 family, since the + // forwarding refcounter is per-family but shared between both families. + family6.ipFwdState = m.family4.ipFwdState + m.ipv6Client = ip6Client - - m.router6, err = newRouter(ip6Client, wgIface, mtu) - if err != nil { - return fmt.Errorf("create v6 router: %w", err) - } - - // Share the same IP forwarding state with the v4 router, since - // EnableIPForwarding controls both v4 and v6 sysctls. - m.router6.ipFwdState = m.router.ipFwdState - - m.aclMgr6, err = newAclManager(ip6Client, wgIface) - if err != nil { - return fmt.Errorf("create v6 acl manager: %w", err) - } + m.family6 = family6 return nil } @@ -109,7 +96,7 @@ func (m *Manager) Init(stateManager *statemanager.Manager) error { InterfaceState: &InterfaceState{ NameStr: m.wgIface.Name(), WGAddress: m.wgIface.Address(), - MTU: m.router.mtu, + MTU: m.family4.mtu, }, } stateManager.RegisterState(state) @@ -141,31 +128,24 @@ func (m *Manager) Init(stateManager *statemanager.Manager) error { return nil } -// initChains initializes router and ACL chains for both address families, -// rolling back on failure. +// initChains initializes the per-family firewall state for both +// address families, rolling back on failure. func (m *Manager) initChains(stateManager *statemanager.Manager) error { type initStep struct { name string - init func(*statemanager.Manager) error - mgr resetter + r *family } - steps := []initStep{ - {"router", m.router.init, m.router}, - {"acl manager", m.aclMgr.init, m.aclMgr}, - } + steps := []initStep{{"v4", m.family4}} if m.hasIPv6() { - steps = append(steps, - initStep{"v6 router", m.router6.init, m.router6}, - initStep{"v6 acl manager", m.aclMgr6.init, m.aclMgr6}, - ) + steps = append(steps, initStep{"v6", m.family6}) } var initialized []initStep for _, s := range steps { - if err := s.init(stateManager); err != nil { + if err := s.r.init(stateManager); err != nil { for i := len(initialized) - 1; i >= 0; i-- { - if rerr := initialized[i].mgr.Reset(); rerr != nil { + if rerr := initialized[i].r.Reset(); rerr != nil { log.Warnf("rollback %s: %v", initialized[i].name, rerr) } } @@ -176,84 +156,50 @@ func (m *Manager) initChains(stateManager *statemanager.Manager) error { return nil } -// AddPeerFiltering adds a rule to the firewall -// -// Comment will be ignored because some system this feature is not supported -func (m *Manager) AddPeerFiltering( - id []byte, - ip net.IP, - proto firewall.Protocol, - sPort *firewall.Port, - dPort *firewall.Port, - action firewall.Action, - ipsetName string, -) ([]firewall.Rule, error) { - m.mutex.Lock() - defer m.mutex.Unlock() - - if ip.To4() != nil { - return m.aclMgr.AddPeerFiltering(id, ip, proto, sPort, dPort, action, ipsetName) - } - if !m.hasIPv6() { - return nil, fmt.Errorf("add peer filtering for %s: %w", ip, firewall.ErrIPv6NotInitialized) - } - return m.aclMgr6.AddPeerFiltering(id, ip, proto, sPort, dPort, action, ipsetName) -} - -func (m *Manager) AddRouteFiltering( +// AddFilterRule installs a packet-filtering rule. See firewall.Manager +// docs for destination semantics. Sources are a single address family; +// the rule is dispatched to the matching v4 / v6 backend. +func (m *Manager) AddFilterRule( id []byte, sources []netip.Prefix, destination firewall.Network, proto firewall.Protocol, - sPort, dPort *firewall.Port, + sPort *firewall.Port, + dPort *firewall.Port, action firewall.Action, ) (firewall.Rule, error) { + if len(sources) == 0 { + return nil, firewall.ErrNoSources + } + m.mutex.Lock() defer m.mutex.Unlock() - if isIPv6RouteRule(sources, destination) { + fam := m.family4 + if isIPv6Rule(sources, destination) { if !m.hasIPv6() { - return nil, fmt.Errorf("add route filtering: %w", firewall.ErrIPv6NotInitialized) + return nil, fmt.Errorf("add filtering: %w", firewall.ErrIPv6NotInitialized) } - return m.router6.AddRouteFiltering(id, sources, destination, proto, sPort, dPort, action) + fam = m.family6 } - - return m.router.AddRouteFiltering(id, sources, destination, proto, sPort, dPort, action) + return fam.AddFilterRule(id, sources, destination, proto, sPort, dPort, action) } -func isIPv6RouteRule(sources []netip.Prefix, destination firewall.Network) bool { - if destination.IsPrefix() { - return destination.Prefix.Addr().Is6() - } - return len(sources) > 0 && sources[0].Addr().Is6() -} - -// DeletePeerRule from the firewall by rule definition -func (m *Manager) DeletePeerRule(rule firewall.Rule) error { +// DeleteFilterRule removes a rule previously added via AddFilterRule. +// The rule is looked up by id in each family's filter cache. +func (m *Manager) DeleteFilterRule(rule firewall.Rule) error { m.mutex.Lock() defer m.mutex.Unlock() - if m.hasIPv6() && isIPv6IptRule(rule) { - return m.aclMgr6.DeletePeerRule(rule) + id := rule.ID() + if m.family4.hasRule(id) { + return m.family4.DeleteFilterRule(rule) } - return m.aclMgr.DeletePeerRule(rule) -} - -func isIPv6IptRule(rule firewall.Rule) bool { - r, ok := rule.(*Rule) - return ok && r.v6 -} - -// DeleteRouteRule deletes a routing rule. -// Route rules are keyed by content hash. Check v4 first, try v6 if not found. -func (m *Manager) DeleteRouteRule(rule firewall.Rule) error { - m.mutex.Lock() - defer m.mutex.Unlock() - - if m.hasIPv6() && !m.router.hasRule(rule.ID()) { - return m.router6.DeleteRouteRule(rule) + if m.hasIPv6() && m.family6.hasRule(id) { + return m.family6.DeleteFilterRule(rule) } - return m.router.DeleteRouteRule(rule) + log.Debugf("filter rule %s not found in any family", id) + return nil } func (m *Manager) IsServerRouteSupported() bool { @@ -272,10 +218,10 @@ func (m *Manager) AddNatRule(pair firewall.RouterPair) error { if !m.hasIPv6() { return fmt.Errorf("add NAT rule: %w", firewall.ErrIPv6NotInitialized) } - return m.router6.AddNatRule(pair) + return m.family6.AddNatRule(pair) } - if err := m.router.AddNatRule(pair); err != nil { + if err := m.family4.AddNatRule(pair); err != nil { return err } @@ -284,7 +230,7 @@ func (m *Manager) AddNatRule(pair firewall.RouterPair) error { // wildcard 0.0.0.0/0 destination where the client resolves DNS. if m.hasIPv6() && pair.Dynamic { v6Pair := firewall.ToV6NatPair(pair) - if err := m.router6.AddNatRule(v6Pair); err != nil { + if err := m.family6.AddNatRule(v6Pair); err != nil { return fmt.Errorf("add v6 NAT rule: %w", err) } } @@ -300,18 +246,18 @@ func (m *Manager) RemoveNatRule(pair firewall.RouterPair) error { if !m.hasIPv6() { return nil } - return m.router6.RemoveNatRule(pair) + return m.family6.RemoveNatRule(pair) } var merr *multierror.Error - if err := m.router.RemoveNatRule(pair); err != nil { + if err := m.family4.RemoveNatRule(pair); err != nil { merr = multierror.Append(merr, fmt.Errorf("remove v4 NAT rule: %w", err)) } if m.hasIPv6() && pair.Dynamic { v6Pair := firewall.ToV6NatPair(pair) - if err := m.router6.RemoveNatRule(v6Pair); err != nil { + if err := m.family6.RemoveNatRule(v6Pair); err != nil { merr = multierror.Append(merr, fmt.Errorf("remove v6 NAT rule: %w", err)) } } @@ -320,11 +266,14 @@ func (m *Manager) RemoveNatRule(pair firewall.RouterPair) error { } func (m *Manager) SetLegacyManagement(isLegacy bool) error { - if err := firewall.SetLegacyManagement(m.router, isLegacy); err != nil { + m.mutex.Lock() + defer m.mutex.Unlock() + + if err := firewall.SetLegacyManagement(m.family4, isLegacy); err != nil { return err } if m.hasIPv6() { - return firewall.SetLegacyManagement(m.router6, isLegacy) + return firewall.SetLegacyManagement(m.family6, isLegacy) } return nil } @@ -341,19 +290,13 @@ func (m *Manager) Close(stateManager *statemanager.Manager) error { } if m.hasIPv6() { - if err := m.aclMgr6.Reset(); err != nil { - merr = multierror.Append(merr, fmt.Errorf("reset v6 acl manager: %w", err)) - } - if err := m.router6.Reset(); err != nil { - merr = multierror.Append(merr, fmt.Errorf("reset v6 router: %w", err)) + if err := m.family6.Reset(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("reset v6 family: %w", err)) } } - if err := m.aclMgr.Reset(); err != nil { - merr = multierror.Append(merr, fmt.Errorf("reset acl manager: %w", err)) - } - if err := m.router.Reset(); err != nil { - merr = multierror.Append(merr, fmt.Errorf("reset router: %w", err)) + if err := m.family4.Reset(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("reset family: %w", err)) } // Appending to merr intentionally blocks DeleteState below so ShutdownState @@ -372,27 +315,6 @@ func (m *Manager) Close(stateManager *statemanager.Manager) error { return nberrors.FormatErrorOrNil(merr) } -// AllowNetbird allows netbird interface traffic. -// This is called when USPFilter wraps the native firewall, adding blanket accept -// rules so that packet filtering is handled in userspace instead of by netfilter. -func (m *Manager) AllowNetbird() error { - var merr *multierror.Error - if _, err := m.AddPeerFiltering(nil, net.IP{0, 0, 0, 0}, firewall.ProtocolALL, nil, nil, firewall.ActionAccept, ""); err != nil { - merr = multierror.Append(merr, fmt.Errorf("allow netbird v4 interface traffic: %w", err)) - } - if m.hasIPv6() { - if _, err := m.AddPeerFiltering(nil, net.IPv6zero, firewall.ProtocolALL, nil, nil, firewall.ActionAccept, ""); err != nil { - merr = multierror.Append(merr, fmt.Errorf("allow netbird v6 interface traffic: %w", err)) - } - } - - if err := firewalld.TrustInterface(m.wgIface.Name()); err != nil { - log.Warnf("failed to trust interface in firewalld: %v", err) - } - - return nberrors.FormatErrorOrNil(merr) -} - // Flush doesn't need to be implemented for this manager func (m *Manager) Flush() error { return nil } @@ -402,17 +324,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.family4.ipFwdState.RequestRouting(m.hasIPv6()) } 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.family4.ipFwdState.ReleaseRouting() } // AddDNATRule adds a DNAT rule @@ -424,9 +341,9 @@ func (m *Manager) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) if !m.hasIPv6() { return nil, fmt.Errorf("add DNAT rule: %w", firewall.ErrIPv6NotInitialized) } - return m.router6.AddDNATRule(rule) + return m.family6.AddDNATRule(rule) } - return m.router.AddDNATRule(rule) + return m.family4.AddDNATRule(rule) } // DeleteDNATRule deletes a DNAT rule @@ -434,10 +351,10 @@ func (m *Manager) DeleteDNATRule(rule firewall.Rule) error { m.mutex.Lock() defer m.mutex.Unlock() - if m.hasIPv6() && !m.router.hasRule(rule.ID()+dnatSuffix) { - return m.router6.DeleteDNATRule(rule) + if m.hasIPv6() && !m.family4.hasDNATRule(rule.ID()) { + return m.family6.DeleteDNATRule(rule) } - return m.router.DeleteDNATRule(rule) + return m.family4.DeleteDNATRule(rule) } // UpdateSet updates the set with the given prefixes @@ -454,12 +371,12 @@ func (m *Manager) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error { } } - if err := m.router.UpdateSet(set, v4Prefixes); err != nil { + if err := m.family4.UpdateSet(set, v4Prefixes); err != nil { return err } if m.hasIPv6() && len(v6Prefixes) > 0 { - if err := m.router6.UpdateSet(set, v6Prefixes); err != nil { + if err := m.family6.UpdateSet(set, v6Prefixes); err != nil { return fmt.Errorf("update v6 set: %w", err) } } @@ -476,9 +393,9 @@ func (m *Manager) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protoco if !m.hasIPv6() { return fmt.Errorf("add inbound DNAT: %w", firewall.ErrIPv6NotInitialized) } - return m.router6.AddInboundDNAT(localAddr, protocol, originalPort, translatedPort) + return m.family6.AddInboundDNAT(localAddr, protocol, originalPort, translatedPort) } - return m.router.AddInboundDNAT(localAddr, protocol, originalPort, translatedPort) + return m.family4.AddInboundDNAT(localAddr, protocol, originalPort, translatedPort) } // RemoveInboundDNAT removes an inbound DNAT rule. @@ -490,9 +407,9 @@ func (m *Manager) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Prot if !m.hasIPv6() { return fmt.Errorf("remove inbound DNAT: %w", firewall.ErrIPv6NotInitialized) } - return m.router6.RemoveInboundDNAT(localAddr, protocol, originalPort, translatedPort) + return m.family6.RemoveInboundDNAT(localAddr, protocol, originalPort, translatedPort) } - return m.router.RemoveInboundDNAT(localAddr, protocol, originalPort, translatedPort) + return m.family4.RemoveInboundDNAT(localAddr, protocol, originalPort, translatedPort) } // AddOutputDNAT adds an OUTPUT chain DNAT rule for locally-generated traffic. @@ -504,9 +421,9 @@ func (m *Manager) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol if !m.hasIPv6() { return fmt.Errorf("add output DNAT: %w", firewall.ErrIPv6NotInitialized) } - return m.router6.AddOutputDNAT(localAddr, protocol, originalPort, translatedPort) + return m.family6.AddOutputDNAT(localAddr, protocol, originalPort, translatedPort) } - return m.router.AddOutputDNAT(localAddr, protocol, originalPort, translatedPort) + return m.family4.AddOutputDNAT(localAddr, protocol, originalPort, translatedPort) } // RemoveOutputDNAT removes an OUTPUT chain DNAT rule. @@ -518,14 +435,14 @@ func (m *Manager) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Proto if !m.hasIPv6() { return fmt.Errorf("remove output DNAT: %w", firewall.ErrIPv6NotInitialized) } - return m.router6.RemoveOutputDNAT(localAddr, protocol, originalPort, translatedPort) + return m.family6.RemoveOutputDNAT(localAddr, protocol, originalPort, translatedPort) } - return m.router.RemoveOutputDNAT(localAddr, protocol, originalPort, translatedPort) + return m.family4.RemoveOutputDNAT(localAddr, protocol, originalPort, translatedPort) } const ( chainNameRaw = "NETBIRD-RAW" - chainOUTPUT = "OUTPUT" + chainOutput = "OUTPUT" tableRaw = "raw" ) @@ -600,15 +517,15 @@ func (m *Manager) initNoTrackChain() error { jumpRule := []string{"-j", chainNameRaw} - if err := m.ipv4Client.InsertUnique(tableRaw, chainOUTPUT, 1, jumpRule...); err != nil { + if err := m.ipv4Client.InsertUnique(tableRaw, chainOutput, 1, jumpRule...); err != nil { if delErr := m.ipv4Client.DeleteChain(tableRaw, chainNameRaw); delErr != nil { log.Debugf("delete orphan chain: %v", delErr) } return fmt.Errorf("add output jump rule: %w", err) } - if err := m.ipv4Client.InsertUnique(tableRaw, chainPREROUTING, 1, jumpRule...); err != nil { - if delErr := m.ipv4Client.DeleteIfExists(tableRaw, chainOUTPUT, jumpRule...); delErr != nil { + if err := m.ipv4Client.InsertUnique(tableRaw, chainPrerouting, 1, jumpRule...); err != nil { + if delErr := m.ipv4Client.DeleteIfExists(tableRaw, chainOutput, jumpRule...); delErr != nil { log.Debugf("delete output jump rule: %v", delErr) } if delErr := m.ipv4Client.DeleteChain(tableRaw, chainNameRaw); delErr != nil { @@ -635,11 +552,11 @@ func (m *Manager) cleanupNoTrackChain() error { jumpRule := []string{"-j", chainNameRaw} - if err := m.ipv4Client.DeleteIfExists(tableRaw, chainOUTPUT, jumpRule...); err != nil { + if err := m.ipv4Client.DeleteIfExists(tableRaw, chainOutput, jumpRule...); err != nil { return fmt.Errorf("remove output jump rule: %w", err) } - if err := m.ipv4Client.DeleteIfExists(tableRaw, chainPREROUTING, jumpRule...); err != nil { + if err := m.ipv4Client.DeleteIfExists(tableRaw, chainPrerouting, jumpRule...); err != nil { return fmt.Errorf("remove prerouting jump rule: %w", err) } @@ -654,3 +571,13 @@ func (m *Manager) cleanupNoTrackChain() error { func getConntrackEstablished() []string { return []string{"-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "ACCEPT"} } + +// isIPv6Rule reports whether the rule belongs to the IPv6 family, from +// the destination prefix when set, otherwise from the (single-family) +// sources. +func isIPv6Rule(sources []netip.Prefix, destination firewall.Network) bool { + if destination.IsPrefix() { + return destination.Prefix.Addr().Is6() + } + return len(sources) > 0 && sources[0].Addr().Is6() +} diff --git a/client/firewall/iptables/manager_linux_test.go b/client/firewall/iptables/manager_linux_test.go index 7b0989f6c..9f53352e1 100644 --- a/client/firewall/iptables/manager_linux_test.go +++ b/client/firewall/iptables/manager_linux_test.go @@ -5,16 +5,19 @@ package iptables import ( "fmt" "net/netip" + "slices" "strings" "testing" "time" "github.com/coreos/go-iptables/iptables" + "github.com/lrh3321/ipset-go" "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" + "github.com/netbirdio/netbird/shared/management/domain" ) var ifaceMock = &iFaceMock{ @@ -67,47 +70,37 @@ func TestIptablesManager(t *testing.T) { time.Sleep(time.Second) }() - var rule2 []fw.Rule + var rule2 fw.Rule t.Run("add second rule", func(t *testing.T) { ip := netip.MustParseAddr("10.20.0.3") port := &fw.Port{ IsRange: true, Values: []uint16{8043, 8046}, } - rule2, err = manager.AddPeerFiltering(nil, ip.AsSlice(), "tcp", port, nil, fw.ActionAccept, "") + rule2, err = manager.AddFilterRule(nil, pfx(ip.AsSlice()), fw.Network{}, "tcp", port, nil, fw.ActionAccept) require.NoError(t, err, "failed to add rule") - for _, r := range rule2 { - rr := r.(*Rule) - checkRuleSpecs(t, ipv4Client, rr.chain, true, rr.specs...) - } + rr := rule2.(*Rule) + checkRuleSpecs(t, ipv4Client, rr.chain, true, rr.specs...) }) t.Run("delete second rule", func(t *testing.T) { - for _, r := range rule2 { - err := manager.DeletePeerRule(r) - require.NoError(t, err, "failed to delete rule") - } - - require.Empty(t, manager.aclMgr.ipsetStore.ipsets, "rulesets index after removed second rule must be empty") + require.NoError(t, manager.DeleteFilterRule(rule2), "failed to delete rule") }) t.Run("reset check", func(t *testing.T) { // add second rule ip := netip.MustParseAddr("10.20.0.3") port := &fw.Port{Values: []uint16{5353}} - _, err = manager.AddPeerFiltering(nil, ip.AsSlice(), "udp", nil, port, fw.ActionAccept, "") + _, err = manager.AddFilterRule(nil, pfx(ip.AsSlice()), fw.Network{}, "udp", nil, port, fw.ActionAccept) require.NoError(t, err, "failed to add rule") err = manager.Close(nil) require.NoError(t, err, "failed to reset") - ok, err := ipv4Client.ChainExists("filter", chainNameInputRules) + ok, err := ipv4Client.ChainExists("filter", chainACLInput) require.NoError(t, err, "failed check chain exists") - - if ok { - require.NoErrorf(t, err, "chain '%v' still exists after Close", chainNameInputRules) - } + require.Falsef(t, ok, "chain %q still exists after Close", chainACLInput) }) } @@ -128,15 +121,13 @@ func TestIptablesManagerDenyRules(t *testing.T) { ip := netip.MustParseAddr("10.20.0.3") port := &fw.Port{Values: []uint16{22}} - rule, err := manager.AddPeerFiltering(nil, ip.AsSlice(), "tcp", nil, port, fw.ActionDrop, "deny-ssh") + rule, err := manager.AddFilterRule(nil, pfx(ip.AsSlice()), fw.Network{}, "tcp", nil, port, fw.ActionDrop) require.NoError(t, err, "failed to add deny rule") - require.NotEmpty(t, rule, "deny rule should not be empty") + require.NotNil(t, rule, "deny rule should not be nil") // Verify the rule was added by checking iptables - for _, r := range rule { - rr := r.(*Rule) - checkRuleSpecs(t, ipv4Client, rr.chain, true, rr.specs...) - } + rr := rule.(*Rule) + checkRuleSpecs(t, ipv4Client, rr.chain, true, rr.specs...) }) t.Run("deny rule precedence test", func(t *testing.T) { @@ -144,36 +135,40 @@ func TestIptablesManagerDenyRules(t *testing.T) { port := &fw.Port{Values: []uint16{80}} // Add accept rule first - _, err := manager.AddPeerFiltering(nil, ip.AsSlice(), "tcp", nil, port, fw.ActionAccept, "accept-http") + _, err := manager.AddFilterRule(nil, pfx(ip.AsSlice()), fw.Network{}, "tcp", nil, port, fw.ActionAccept) require.NoError(t, err, "failed to add accept rule") // Add deny rule second for same IP/port - this should take precedence - _, err = manager.AddPeerFiltering(nil, ip.AsSlice(), "tcp", nil, port, fw.ActionDrop, "deny-http") + _, err = manager.AddFilterRule(nil, pfx(ip.AsSlice()), fw.Network{}, "tcp", nil, port, fw.ActionDrop) require.NoError(t, err, "failed to add deny rule") // Inspect the actual iptables rules to verify deny rule comes before accept rule - rules, err := ipv4Client.List("filter", chainNameInputRules) + rules, err := ipv4Client.List("filter", chainACLInput) require.NoError(t, err, "failed to list iptables rules") // Debug: print all rules - t.Logf("All iptables rules in chain %s:", chainNameInputRules) + t.Logf("All iptables rules in chain %s:", chainACLInput) for i, rule := range rules { t.Logf(" [%d] %s", i, rule) } + // Single-source rules emit a direct `-s /32 ... --dport 80` + // match. Match on that shape instead of the legacy + // per-(action,port) ipset names ("deny-http"/"accept-http") + // that this test predates. + srcMatch := fmt.Sprintf("-s %s/32", ip) var denyRuleIndex, acceptRuleIndex = -1, -1 for i, rule := range rules { - if strings.Contains(rule, "DROP") { - t.Logf("Found DROP rule at index %d: %s", i, rule) - if strings.Contains(rule, "deny-http") && strings.Contains(rule, "80") { - denyRuleIndex = i - } + if !strings.Contains(rule, srcMatch) || !strings.Contains(rule, "--dport 80") { + continue } - if strings.Contains(rule, "ACCEPT") { + if strings.Contains(rule, "-j DROP") { + t.Logf("Found DROP rule at index %d: %s", i, rule) + denyRuleIndex = i + } + if strings.Contains(rule, "-j ACCEPT") { t.Logf("Found ACCEPT rule at index %d: %s", i, rule) - if strings.Contains(rule, "accept-http") && strings.Contains(rule, "80") { - acceptRuleIndex = i - } + acceptRuleIndex = i } } @@ -198,7 +193,6 @@ func TestIptablesManagerIPSet(t *testing.T) { }, } - // just check on the local interface manager, err := Create(mock, iface.DefaultMTU) require.NoError(t, err) require.NoError(t, manager.Init(nil)) @@ -212,27 +206,39 @@ func TestIptablesManagerIPSet(t *testing.T) { time.Sleep(time.Second) }() - var rule2 []fw.Rule - t.Run("add second rule", func(t *testing.T) { + var rule2 fw.Rule + t.Run("single source uses direct -s match (no ipset)", func(t *testing.T) { ip := netip.MustParseAddr("10.20.0.3") port := &fw.Port{ Values: []uint16{443}, } - rule2, err = manager.AddPeerFiltering(nil, ip.AsSlice(), "tcp", port, nil, fw.ActionAccept, "default") - for _, r := range rule2 { - require.NoError(t, err, "failed to add rule") - require.Equal(t, r.(*Rule).ipsetName, "default-sport", "ipset name must be set") - require.Equal(t, r.(*Rule).ip, "10.20.0.3", "ipset IP must be set") - } + rule2, err = manager.AddFilterRule(nil, pfx(ip.AsSlice()), fw.Network{}, "tcp", port, nil, fw.ActionAccept) + require.NoError(t, err, "failed to add rule") + require.NotNil(t, rule2) + require.Contains(t, rule2.(*Rule).specs, "-s", + "single-source rule should use direct -s match, not an ipset") + require.Empty(t, findSets(rule2.(*Rule).specs), + "single-source rule should not allocate a shared ipset") }) - t.Run("delete second rule", func(t *testing.T) { - for _, r := range rule2 { - err := manager.DeletePeerRule(r) - require.NoError(t, err, "failed to delete rule") + t.Run("delete single-source rule", func(t *testing.T) { + require.NoError(t, manager.DeleteFilterRule(rule2), "failed to delete rule") + }) - require.Empty(t, manager.aclMgr.ipsetStore.ipsets, "rulesets index after removed second rule must be empty") + t.Run("multi-source uses shared ipset", func(t *testing.T) { + sources := []netip.Prefix{ + netip.PrefixFrom(netip.MustParseAddr("10.20.0.3"), 32), + netip.PrefixFrom(netip.MustParseAddr("10.20.0.4"), 32), + netip.PrefixFrom(netip.MustParseAddr("10.20.0.5"), 32), } + port := &fw.Port{Values: []uint16{8080}} + multi, err := manager.AddFilterRule(nil, sources, fw.Network{}, "tcp", nil, port, fw.ActionAccept) + require.NoError(t, err, "failed to add multi-source rule") + require.NotNil(t, multi, "multi-source rule must produce one iptables rule") + sets := findSets(multi.(*Rule).specs) + require.Len(t, sets, 1, "multi-source rule must reference exactly one ipset") + + require.NoError(t, manager.DeleteFilterRule(multi)) }) t.Run("reset check", func(t *testing.T) { @@ -241,9 +247,324 @@ func TestIptablesManagerIPSet(t *testing.T) { }) } +// TestIptablesFilterIPSetFallback verifies that when the kernel lacks +// ipset support, a multi-source rule falls back to one iptables rule +// per source prefix instead of silently leaving the chain empty. See +// discussion #6125. +func TestIptablesFilterIPSetFallback(t *testing.T) { + ipv4Client, err := iptables.NewWithProtocol(iptables.ProtocolIPv4) + require.NoError(t, err) + + manager, err := Create(ifaceMock, iface.DefaultMTU) + require.NoError(t, err) + require.NoError(t, manager.Init(nil)) + + defer func() { + require.NoError(t, manager.Close(nil)) + }() + + // Simulate a kernel without the ipset hash module. + manager.family4.ipsetSupported = false + + sources := []netip.Prefix{ + netip.MustParsePrefix("10.20.0.42/32"), + netip.MustParsePrefix("10.20.0.43/32"), + } + port := &fw.Port{Values: []uint16{22}} + + rule, err := manager.AddFilterRule(nil, sources, fw.Network{}, "tcp", nil, port, fw.ActionAccept) + require.NoError(t, err, "AddFilterRule should succeed via fallback") + + rr := rule.(*Rule) + all := rr.allSpecs() + require.Len(t, all, len(sources), "each source prefix needs its own rule") + for i, fs := range all { + joined := strings.Join(fs.specs, " ") + require.Contains(t, joined, "-s "+sources[i].String(), "fallback rule must match by source prefix") + require.NotContains(t, joined, matchSet, "fallback rule must not use ipset matching") + + // The rule must actually be present in the ACL chain (not silently dropped). + checkRuleSpecs(t, ipv4Client, rr.chain, true, fs.specs...) + + // Every expanded peer rule keeps its own redirect-mark pairing. + require.NotNil(t, fs.mangleSpecs, "peer rule must carry a mangle pairing") + checkTableRuleSpecs(t, ipv4Client, tableMangle, chainRTPre, true, fs.mangleSpecs...) + } + + require.NoError(t, manager.DeleteFilterRule(rule), "failed to delete fallback rule") + for _, fs := range all { + checkRuleSpecs(t, ipv4Client, rr.chain, false, fs.specs...) + checkTableRuleSpecs(t, ipv4Client, tableMangle, chainRTPre, false, fs.mangleSpecs...) + } +} + +// TestIptablesFilterDestinationSetRequiresIPSet documents that a dynamic +// (domain) destination cannot be expressed without ipset: its prefixes are only +// known after DNS resolution, so there is nothing to expand into per-prefix +// rules. The call must report that rather than install a broader rule than the +// policy allows. +func TestIptablesFilterDestinationSetRequiresIPSet(t *testing.T) { + manager, err := Create(ifaceMock, iface.DefaultMTU) + require.NoError(t, err) + require.NoError(t, manager.Init(nil)) + + defer func() { + require.NoError(t, manager.Close(nil)) + }() + + manager.family4.ipsetSupported = false + + destination := fw.Network{Set: fw.NewDomainSet(domain.List{"example.com"})} + + _, err = manager.AddFilterRule(nil, []netip.Prefix{netip.MustParsePrefix("172.16.0.0/16")}, + destination, fw.ProtocolALL, nil, nil, fw.ActionAccept) + require.Error(t, err, "a domain destination is not expressible without ipset") + require.ErrorContains(t, err, "requires ipset") +} + +// TestIptablesNatRuleDropsSourceSetOnDestinationFailure covers a marking rule +// whose source set is created but whose destination set is not: the source +// reference has to go back, or the set it created stays in the kernel with a +// count nothing will ever drop. +func TestIptablesNatRuleDropsSourceSetOnDestinationFailure(t *testing.T) { + manager, err := Create(ifaceMock, iface.DefaultMTU) + require.NoError(t, err) + require.NoError(t, manager.Init(nil)) + + defer func() { + require.NoError(t, manager.Close(nil)) + }() + + sourceSet := fw.NewPrefixSet([]netip.Prefix{ + netip.MustParsePrefix("100.0.0.0/16"), + netip.MustParsePrefix("10.10.0.0/16"), + }) + destSet := fw.NewDomainSet(domain.List{"example.org"}) + + // Poison the destination set's name so its hash:net creation fails after + // the source set has already been created. + poisoned := manager.family4.ipsetName(destSet.HashedName()) + require.NoError(t, ipset.Create(poisoned, ipset.TypeHashIP, ipset.CreateOptions{})) + t.Cleanup(func() { + if err := ipset.Destroy(poisoned); err != nil { + t.Logf("destroy poisoned set %s: %v", poisoned, err) + } + }) + + pair := fw.RouterPair{ + ID: "nat-source-set-test", + Source: fw.Network{Set: sourceSet}, + Destination: fw.Network{Set: destSet}, + Masquerade: true, + Dynamic: true, + } + + require.Error(t, manager.AddNatRule(pair), "the destination set must fail to be created") + + _, ok := manager.family4.ipsetCounter.Get(manager.family4.ipsetName(sourceSet.HashedName())) + require.False(t, ok, "the source set reference must be released") +} + +// TestIptablesNatRuleReAddKeepsSetReferences re-adds the same NAT rule the way +// a repeated network-map update does. The marking rule's set references must not +// grow, or RemoveNatRule can never drop the count to zero and the set stays in +// the kernel for the rest of the process lifetime. +func TestIptablesNatRuleReAddKeepsSetReferences(t *testing.T) { + manager, err := Create(ifaceMock, iface.DefaultMTU) + require.NoError(t, err) + require.NoError(t, manager.Init(nil)) + + defer func() { + require.NoError(t, manager.Close(nil)) + }() + + set := fw.NewDomainSet(domain.List{"example.com"}) + pair := fw.RouterPair{ + ID: "nat-reference-test", + Source: fw.Network{Prefix: netip.MustParsePrefix("100.0.0.0/16")}, + Destination: fw.Network{Set: set}, + Masquerade: true, + Dynamic: true, + } + + require.NoError(t, manager.AddNatRule(pair), "add nat rule") + name := manager.family4.ipsetName(set.HashedName()) + first, ok := manager.family4.ipsetCounter.Get(name) + require.True(t, ok, "the marking rule must hold a reference to its set") + + require.NoError(t, manager.AddNatRule(pair), "re-add nat rule") + second, ok := manager.family4.ipsetCounter.Get(name) + require.True(t, ok, "the set must still be referenced") + require.Equal(t, first.Count, second.Count, "re-adding the same rule must not add references") + + require.NoError(t, manager.RemoveNatRule(pair), "remove nat rule") + _, ok = manager.family4.ipsetCounter.Get(name) + require.False(t, ok, "removing the rule must drop the last reference") +} + +// TestIptablesRouteFilterIPSetFallback covers the route ACL side of the +// fallback: with a destination set, the expanded per-source rules land +// in the route forward chain and are all removed on delete. +func TestIptablesRouteFilterIPSetFallback(t *testing.T) { + ipv4Client, err := iptables.NewWithProtocol(iptables.ProtocolIPv4) + require.NoError(t, err) + + manager, err := Create(ifaceMock, iface.DefaultMTU) + require.NoError(t, err) + require.NoError(t, manager.Init(nil)) + + defer func() { + require.NoError(t, manager.Close(nil)) + }() + + manager.family4.ipsetSupported = false + + sources := []netip.Prefix{ + netip.MustParsePrefix("172.16.0.0/16"), + netip.MustParsePrefix("192.168.0.0/16"), + } + destination := fw.Network{Prefix: netip.MustParsePrefix("10.0.0.0/8")} + port := &fw.Port{Values: []uint16{443}} + + rule, err := manager.AddFilterRule(nil, sources, destination, "tcp", nil, port, fw.ActionAccept) + require.NoError(t, err, "route ACL must install without ipset") + + rr := rule.(*Rule) + require.Equal(t, chainRTFwdIn, rr.chain, "route rule must land in the forward chain") + + all := rr.allSpecs() + require.Len(t, all, len(sources), "each source prefix needs its own rule") + for i, fs := range all { + joined := strings.Join(fs.specs, " ") + require.Contains(t, joined, "-s "+sources[i].String(), "fallback rule must match by source prefix") + require.NotContains(t, joined, matchSet, "fallback rule must not use ipset matching") + require.Nil(t, fs.mangleSpecs, "route rules have no mangle pairing") + + checkRuleSpecs(t, ipv4Client, rr.chain, true, fs.specs...) + } + + require.NoError(t, manager.DeleteFilterRule(rule), "failed to delete fallback rule") + for _, fs := range all { + checkRuleSpecs(t, ipv4Client, rr.chain, false, fs.specs...) + } +} + +// TestIptablesCloseRemovesAllState exercises a spread of rule kinds and then +// asserts Close puts every table it touches back exactly as it found it. A +// leaked chain, jump, or ipset survives the daemon and nothing can remove it +// afterwards, since the tracking that knew about it is gone. +func TestIptablesCloseRemovesAllState(t *testing.T) { + ipv4Client, err := iptables.NewWithProtocol(iptables.ProtocolIPv4) + require.NoError(t, err) + + before := snapshotIptables(t, ipv4Client) + + manager, err := Create(ifaceMock, iface.DefaultMTU) + require.NoError(t, err) + require.NoError(t, manager.Init(nil)) + + // A failed assertion below returns before the Close under test, which would + // leave this test's chains and sets in the kernel for the next one. + t.Cleanup(func() { + if err := manager.Close(nil); err != nil { + t.Logf("close after failure: %v", err) + } + }) + + sources := []netip.Prefix{ + netip.MustParsePrefix("10.20.0.42/32"), + netip.MustParsePrefix("10.20.0.43/32"), + } + + // A multi-source peer rule: shared ipset plus the mangle redirect pairing. + _, err = manager.AddFilterRule(nil, sources, fw.Network{}, "tcp", + nil, &fw.Port{Values: []uint16{22}}, fw.ActionAccept) + require.NoError(t, err, "add peer rule") + + // A route rule with a dynamic destination: a second set, in the forward chain. + _, err = manager.AddFilterRule(nil, sources, + fw.Network{Set: fw.NewDomainSet(domain.List{"example.com"})}, + fw.ProtocolALL, nil, nil, fw.ActionDrop) + require.NoError(t, err, "add route rule") + + // NAT marking for a routed destination, both directions. + pair := fw.RouterPair{ + ID: "cleanup-test", + Source: fw.Network{Prefix: netip.MustParsePrefix("100.0.0.0/16")}, + Destination: fw.Network{Prefix: netip.MustParsePrefix("192.168.55.0/24")}, + Masquerade: true, + } + require.NoError(t, manager.AddNatRule(pair), "add nat rule") + require.NoError(t, manager.EnableRouting(), "enable routing") + + // A DNAT redirect, which also holds a forwarding reference. + dnat := fw.ForwardRule{ + Protocol: fw.ProtocolTCP, + DestinationPort: fw.Port{Values: []uint16{8080}}, + TranslatedAddress: netip.MustParseAddr("10.20.0.44"), + TranslatedPort: fw.Port{Values: []uint16{80}}, + } + _, err = manager.AddDNATRule(dnat) + require.NoError(t, err, "add dnat rule") + + require.NotEqual(t, before, snapshotIptables(t, ipv4Client), "the manager must have installed state") + + // Everything above stays in place, so Close is what has to remove it. + require.NoError(t, manager.Close(nil), "close") + + after := snapshotIptables(t, ipv4Client) + require.Equal(t, before.chains, after.chains, "Close must remove every chain it created") + require.Equal(t, before.rules, after.rules, "Close must remove every rule it created") + require.Equal(t, before.sets, after.sets, "Close must destroy every ipset it created") +} + +// iptablesState is a snapshot of the tables the manager writes to, used to +// compare the kernel before and after a manager lifetime. +type iptablesState struct { + chains map[string][]string + rules map[string][]string + sets []string +} + +func snapshotIptables(t *testing.T, client *iptables.IPTables) iptablesState { + t.Helper() + + state := iptablesState{ + chains: map[string][]string{}, + rules: map[string][]string{}, + } + + for _, table := range []string{tableFilter, tableNat, tableMangle, tableRaw} { + chains, err := client.ListChains(table) + require.NoErrorf(t, err, "list chains in %s", table) + slices.Sort(chains) + state.chains[table] = chains + + for _, chain := range chains { + rules, err := client.List(table, chain) + require.NoErrorf(t, err, "list rules in %s/%s", table, chain) + state.rules[table+"/"+chain] = rules + } + } + + sets, err := ipset.ListAll() + require.NoError(t, err, "list ipsets") + for _, set := range sets { + state.sets = append(state.sets, set.SetName) + } + slices.Sort(state.sets) + + return state +} + func checkRuleSpecs(t *testing.T, ipv4Client *iptables.IPTables, chainName string, mustExists bool, rulespec ...string) { t.Helper() - exists, err := ipv4Client.Exists("filter", chainName, rulespec...) + checkTableRuleSpecs(t, ipv4Client, tableFilter, chainName, mustExists, rulespec...) +} + +func checkTableRuleSpecs(t *testing.T, ipv4Client *iptables.IPTables, table, chainName string, mustExists bool, rulespec ...string) { + t.Helper() + exists, err := ipv4Client.Exists(table, chainName, rulespec...) require.NoError(t, err, "failed to check rule") require.Falsef(t, !exists && mustExists, "rule '%v' does not exist", rulespec) require.Falsef(t, exists && !mustExists, "rule '%v' exist", rulespec) @@ -283,7 +604,7 @@ func TestIptablesCreatePerformance(t *testing.T) { start := time.Now() for i := 0; i < testMax; i++ { port := &fw.Port{Values: []uint16{uint16(1000 + i)}} - _, err = manager.AddPeerFiltering(nil, ip.AsSlice(), "tcp", nil, port, fw.ActionAccept, "") + _, err = manager.AddFilterRule(nil, pfx(ip.AsSlice()), fw.Network{}, "tcp", nil, port, fw.ActionAccept) require.NoError(t, err, "failed to add rule") } diff --git a/client/firewall/iptables/router_linux.go b/client/firewall/iptables/router_linux.go deleted file mode 100644 index 42d305f5c..000000000 --- a/client/firewall/iptables/router_linux.go +++ /dev/null @@ -1,1154 +0,0 @@ -//go:build !android - -package iptables - -import ( - "fmt" - "maps" - "net/netip" - "strconv" - "strings" - - "github.com/coreos/go-iptables/iptables" - "github.com/hashicorp/go-multierror" - ipset "github.com/lrh3321/ipset-go" - log "github.com/sirupsen/logrus" - - nberrors "github.com/netbirdio/netbird/client/errors" - firewall "github.com/netbirdio/netbird/client/firewall/manager" - nbid "github.com/netbirdio/netbird/client/internal/acl/id" - "github.com/netbirdio/netbird/client/internal/routemanager/ipfwdstate" - "github.com/netbirdio/netbird/client/internal/routemanager/refcounter" - "github.com/netbirdio/netbird/client/internal/statemanager" - nbnet "github.com/netbirdio/netbird/client/net" -) - -// constants needed to manage and create iptable rules -const ( - tableFilter = "filter" - tableNat = "nat" - tableMangle = "mangle" - - chainPOSTROUTING = "POSTROUTING" - chainPREROUTING = "PREROUTING" - chainFORWARD = "FORWARD" - chainRTNAT = "NETBIRD-RT-NAT" - chainRTFWDIN = "NETBIRD-RT-FWD-IN" - chainRTFWDOUT = "NETBIRD-RT-FWD-OUT" - chainRTPRE = "NETBIRD-RT-PRE" - chainRTRDR = "NETBIRD-RT-RDR" - chainNATOutput = "NETBIRD-NAT-OUTPUT" - chainRTMSSCLAMP = "NETBIRD-RT-MSSCLAMP" - routingFinalForwardJump = "ACCEPT" - routingFinalNatJump = "MASQUERADE" - - jumpManglePre = "jump-mangle-pre" - jumpNatPre = "jump-nat-pre" - jumpNatPost = "jump-nat-post" - jumpNatOutput = "jump-nat-output" - jumpMSSClamp = "jump-mss-clamp" - markManglePre = "mark-mangle-pre" - markManglePost = "mark-mangle-post" - matchSet = "--match-set" - - dnatSuffix = "_dnat" - snatSuffix = "_snat" - fwdSuffix = "_fwd" - - // ipv4TCPHeaderSize is the minimum IPv4 (20) + TCP (20) header size for MSS calculation. - ipv4TCPHeaderSize = 40 - // ipv6TCPHeaderSize is the minimum IPv6 (40) + TCP (20) header size for MSS calculation. - ipv6TCPHeaderSize = 60 -) - -type ruleInfo struct { - chain string - table string - rule []string -} - -type routeFilteringRuleParams struct { - Source firewall.Network - Destination firewall.Network - Proto firewall.Protocol - SPort *firewall.Port - DPort *firewall.Port - Direction firewall.RuleDirection - Action firewall.Action -} - -type routeRules map[string][]string - -// the ipset library currently does not support comments, so we use the name only (string) -type ipsetCounter = refcounter.Counter[string, []netip.Prefix, struct{}] - -type router struct { - iptablesClient *iptables.IPTables - rules routeRules - ipsetCounter *ipsetCounter - wgIface iFaceMapper - legacyManagement bool - mtu uint16 - v6 bool - - stateManager *statemanager.Manager - ipFwdState *ipfwdstate.IPForwardingState -} - -func newRouter(iptablesClient *iptables.IPTables, wgIface iFaceMapper, mtu uint16) (*router, error) { - r := &router{ - iptablesClient: iptablesClient, - rules: make(map[string][]string), - wgIface: wgIface, - mtu: mtu, - v6: iptablesClient.Proto() == iptables.ProtocolIPv6, - ipFwdState: ipfwdstate.NewIPForwardingState(), - } - - r.ipsetCounter = refcounter.New( - func(name string, sources []netip.Prefix) (struct{}, error) { - return struct{}{}, r.createIpSet(name, sources) - }, - func(name string, _ struct{}) error { - return r.deleteIpSet(name) - }, - ) - - return r, nil -} - -func (r *router) init(stateManager *statemanager.Manager) error { - r.stateManager = stateManager - - if err := r.cleanUpDefaultForwardRules(); err != nil { - log.Errorf("failed to clean up rules from FORWARD chain: %s", err) - } - - if err := r.createContainers(); err != nil { - return fmt.Errorf("create containers: %w", err) - } - - if err := r.setupDataPlaneMark(); err != nil { - log.Errorf("failed to set up data plane mark: %v", err) - } - - r.updateState() - - return nil -} - -func (r *router) AddRouteFiltering( - id []byte, - sources []netip.Prefix, - destination firewall.Network, - proto firewall.Protocol, - sPort *firewall.Port, - dPort *firewall.Port, - action firewall.Action, -) (firewall.Rule, error) { - ruleKey := nbid.GenerateRouteRuleKey(sources, destination, proto, sPort, dPort, action) - if _, ok := r.rules[string(ruleKey)]; ok { - return ruleKey, nil - } - - var source firewall.Network - if len(sources) > 1 { - source.Set = firewall.NewPrefixSet(sources) - } else if len(sources) > 0 { - source.Prefix = sources[0] - } - - params := routeFilteringRuleParams{ - Source: source, - Destination: destination, - Proto: proto, - SPort: sPort, - DPort: dPort, - Action: action, - } - - rule, err := r.genRouteRuleSpec(params, sources) - if err != nil { - return nil, fmt.Errorf("generate route rule spec: %w", err) - } - - // Insert DROP rules at the beginning, append ACCEPT rules at the end - if action == firewall.ActionDrop { - // after the established rule - err = r.iptablesClient.Insert(tableFilter, chainRTFWDIN, 2, rule...) - } else { - err = r.iptablesClient.Append(tableFilter, chainRTFWDIN, rule...) - } - - if err != nil { - return nil, fmt.Errorf("add route rule: %v", err) - } - - r.rules[string(ruleKey)] = rule - - r.updateState() - - return ruleKey, nil -} - -func (r *router) hasRule(id string) bool { - _, ok := r.rules[id] - return ok -} - -func (r *router) DeleteRouteRule(rule firewall.Rule) error { - ruleKey := rule.ID() - - if rule, exists := r.rules[ruleKey]; exists { - if err := r.iptablesClient.Delete(tableFilter, chainRTFWDIN, rule...); err != nil { - return fmt.Errorf("delete route rule: %v", err) - } - delete(r.rules, ruleKey) - - if err := r.decrementSetCounter(rule); err != nil { - return fmt.Errorf("decrement ipset counter: %w", err) - } - } else { - log.Debugf("route rule %s not found", ruleKey) - } - - r.updateState() - - return nil -} - -func (r *router) decrementSetCounter(rule []string) error { - sets := r.findSets(rule) - var merr *multierror.Error - for _, setName := range sets { - if _, err := r.ipsetCounter.Decrement(setName); err != nil { - merr = multierror.Append(merr, fmt.Errorf("decrement counter: %w", err)) - } - } - - return nberrors.FormatErrorOrNil(merr) -} - -func (r *router) findSets(rule []string) []string { - var sets []string - for i, arg := range rule { - if arg == "-m" && i+3 < len(rule) && rule[i+1] == "set" && rule[i+2] == matchSet { - sets = append(sets, rule[i+3]) - } - } - return sets -} - -func (r *router) createIpSet(setName string, sources []netip.Prefix) error { - if err := r.createIPSet(setName); err != nil { - return fmt.Errorf("create set %s: %w", setName, err) - } - - for _, prefix := range sources { - if err := r.addPrefixToIPSet(setName, prefix); err != nil { - return fmt.Errorf("add element to set %s: %w", setName, err) - } - } - - return nil -} - -func (r *router) deleteIpSet(setName string) error { - if err := r.destroyIPSet(setName); err != nil { - return fmt.Errorf("destroy set %s: %w", setName, err) - } - - log.Debugf("Deleted unused ipset %s", setName) - return nil -} - -// AddNatRule inserts an iptables rule pair into the nat chain -func (r *router) AddNatRule(pair firewall.RouterPair) error { - if r.legacyManagement { - log.Warnf("This peer is connected to a NetBird Management service with an older version. Allowing all traffic for %s", pair.Destination) - if err := r.addLegacyRouteRule(pair); err != nil { - return fmt.Errorf("add legacy routing rule: %w", err) - } - } - - if !pair.Masquerade { - return nil - } - - if err := r.addNatRule(pair); err != nil { - return fmt.Errorf("add nat rule: %w", err) - } - - if err := r.addNatRule(firewall.GetInversePair(pair)); err != nil { - return fmt.Errorf("add inverse nat rule: %w", err) - } - - r.updateState() - - return nil -} - -// RemoveNatRule removes an iptables rule pair from forwarding and nat chains -func (r *router) RemoveNatRule(pair firewall.RouterPair) error { - if pair.Masquerade { - if err := r.removeNatRule(pair); err != nil { - return fmt.Errorf("remove nat rule: %w", err) - } - - if err := r.removeNatRule(firewall.GetInversePair(pair)); err != nil { - return fmt.Errorf("remove inverse nat rule: %w", err) - } - } - - if err := r.removeLegacyRouteRule(pair); err != nil { - return fmt.Errorf("remove legacy routing rule: %w", err) - } - - r.updateState() - - return nil -} - -// addLegacyRouteRule adds a legacy routing rule for mgmt servers pre route acls -func (r *router) addLegacyRouteRule(pair firewall.RouterPair) error { - ruleKey := firewall.GenKey(firewall.ForwardingFormat, pair) - - if err := r.removeLegacyRouteRule(pair); err != nil { - return err - } - - rule := []string{"-s", pair.Source.String(), "-d", pair.Destination.String(), "-j", routingFinalForwardJump} - if err := r.iptablesClient.Append(tableFilter, chainRTFWDIN, rule...); err != nil { - return fmt.Errorf("add legacy forwarding rule %s -> %s: %v", pair.Source, pair.Destination, err) - } - - r.rules[ruleKey] = rule - - return nil -} - -func (r *router) removeLegacyRouteRule(pair firewall.RouterPair) error { - ruleKey := firewall.GenKey(firewall.ForwardingFormat, pair) - - if rule, exists := r.rules[ruleKey]; exists { - if err := r.iptablesClient.DeleteIfExists(tableFilter, chainRTFWDIN, rule...); err != nil { - return fmt.Errorf("remove legacy forwarding rule %s -> %s: %v", pair.Source, pair.Destination, err) - } - delete(r.rules, ruleKey) - - if err := r.decrementSetCounter(rule); err != nil { - return fmt.Errorf("decrement ipset counter: %w", err) - } - } - - return nil -} - -// GetLegacyManagement returns the current legacy management mode -func (r *router) GetLegacyManagement() bool { - return r.legacyManagement -} - -// SetLegacyManagement sets the route manager to use legacy management mode -func (r *router) SetLegacyManagement(isLegacy bool) { - r.legacyManagement = isLegacy -} - -// RemoveAllLegacyRouteRules removes all legacy routing rules for mgmt servers pre route acls -func (r *router) RemoveAllLegacyRouteRules() error { - var merr *multierror.Error - for k, rule := range r.rules { - if !strings.HasPrefix(k, firewall.ForwardingFormatPrefix) { - continue - } - if err := r.iptablesClient.DeleteIfExists(tableFilter, chainRTFWDIN, rule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove legacy forwarding rule: %v", err)) - } else { - delete(r.rules, k) - } - } - - r.updateState() - - return nberrors.FormatErrorOrNil(merr) -} - -func (r *router) Reset() error { - var merr *multierror.Error - if err := r.cleanUpDefaultForwardRules(); err != nil { - merr = multierror.Append(merr, err) - } - - if err := r.ipsetCounter.Flush(); err != nil { - merr = multierror.Append(merr, err) - } - - if err := r.cleanupDataPlaneMark(); err != nil { - merr = multierror.Append(merr, err) - } - - r.rules = make(map[string][]string) - r.updateState() - - return nberrors.FormatErrorOrNil(merr) -} - -func (r *router) cleanUpDefaultForwardRules() error { - if err := r.cleanJumpRules(); err != nil { - return fmt.Errorf("clean jump rules: %w", err) - } - - log.Debug("flushing routing related tables") - - // Remove jump rules from built-in chains before deleting custom chains, - // otherwise the chain deletion fails with "device or resource busy". - if ok, err := r.iptablesClient.ChainExists(tableNat, chainNATOutput); err != nil { - return fmt.Errorf("check chain %s: %w", chainNATOutput, err) - } else if ok { - jumpRule := []string{"-j", chainNATOutput} - if err := r.iptablesClient.Delete(tableNat, "OUTPUT", jumpRule...); err != nil { - log.Debugf("clean OUTPUT jump rule: %v", err) - } - } - - for _, chainInfo := range []struct { - chain string - table string - }{ - {chainRTFWDIN, tableFilter}, - {chainRTFWDOUT, tableFilter}, - {chainRTPRE, tableMangle}, - {chainRTNAT, tableNat}, - {chainRTRDR, tableNat}, - {chainNATOutput, tableNat}, - {chainRTMSSCLAMP, tableMangle}, - } { - ok, err := r.iptablesClient.ChainExists(chainInfo.table, chainInfo.chain) - if err != nil { - return fmt.Errorf("check chain %s in table %s: %w", chainInfo.chain, chainInfo.table, err) - } else if ok { - if err = r.iptablesClient.ClearAndDeleteChain(chainInfo.table, chainInfo.chain); err != nil { - return fmt.Errorf("clear and delete chain %s in table %s: %w", chainInfo.chain, chainInfo.table, err) - } - } - } - - return nil -} - -func (r *router) createContainers() error { - for _, chainInfo := range []struct { - chain string - table string - }{ - {chainRTFWDIN, tableFilter}, - {chainRTFWDOUT, tableFilter}, - {chainRTPRE, tableMangle}, - {chainRTNAT, tableNat}, - {chainRTRDR, tableNat}, - {chainRTMSSCLAMP, tableMangle}, - } { - // Fallback: clear chains that survived an unclean shutdown. - if ok, _ := r.iptablesClient.ChainExists(chainInfo.table, chainInfo.chain); ok { - if err := r.iptablesClient.ClearAndDeleteChain(chainInfo.table, chainInfo.chain); err != nil { - log.Warnf("clear stale chain %s in %s: %v", chainInfo.chain, chainInfo.table, err) - } - } - if err := r.iptablesClient.NewChain(chainInfo.table, chainInfo.chain); err != nil { - return fmt.Errorf("create chain %s in table %s: %w", chainInfo.chain, chainInfo.table, err) - } - } - - if err := r.insertEstablishedRule(chainRTFWDIN); err != nil { - return fmt.Errorf("insert established rule: %w", err) - } - - if err := r.insertEstablishedRule(chainRTFWDOUT); err != nil { - return fmt.Errorf("insert established rule: %w", err) - } - - if err := r.addPostroutingRules(); err != nil { - return fmt.Errorf("add static nat rules: %w", err) - } - - if err := r.addJumpRules(); err != nil { - return fmt.Errorf("add jump rules: %w", err) - } - - if err := r.addMSSClampingRules(); err != nil { - log.Errorf("failed to add MSS clamping rules: %s", err) - } - - return nil -} - -// setupDataPlaneMark configures the fwmark for the data plane -func (r *router) setupDataPlaneMark() error { - var merr *multierror.Error - preRule := []string{ - "-i", r.wgIface.Name(), - "-m", "conntrack", "--ctstate", "NEW", - "-j", "CONNMARK", "--set-mark", fmt.Sprintf("%#x", nbnet.DataPlaneMarkIn), - } - - if err := r.iptablesClient.AppendUnique(tableMangle, chainPREROUTING, preRule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("add mangle prerouting rule: %w", err)) - } else { - r.rules[markManglePre] = preRule - } - - postRule := []string{ - "-o", r.wgIface.Name(), - "-m", "conntrack", "--ctstate", "NEW", - "-j", "CONNMARK", "--set-mark", fmt.Sprintf("%#x", nbnet.DataPlaneMarkOut), - } - - if err := r.iptablesClient.AppendUnique(tableMangle, chainPOSTROUTING, postRule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("add mangle postrouting rule: %w", err)) - } else { - r.rules[markManglePost] = postRule - } - - return nberrors.FormatErrorOrNil(merr) -} - -func (r *router) cleanupDataPlaneMark() error { - var merr *multierror.Error - if preRule, exists := r.rules[markManglePre]; exists { - if err := r.iptablesClient.DeleteIfExists(tableMangle, chainPREROUTING, preRule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove mangle prerouting rule: %w", err)) - } else { - delete(r.rules, markManglePre) - } - } - - if postRule, exists := r.rules[markManglePost]; exists { - if err := r.iptablesClient.DeleteIfExists(tableMangle, chainPOSTROUTING, postRule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove mangle postrouting rule: %w", err)) - } else { - delete(r.rules, markManglePost) - } - } - - return nberrors.FormatErrorOrNil(merr) -} - -func (r *router) addPostroutingRules() error { - // First rule for outbound masquerade - rule1 := []string{ - "-m", "mark", "--mark", fmt.Sprintf("%#x", nbnet.PreroutingFwmarkMasquerade), - "!", "-o", "lo", - "-j", routingFinalNatJump, - } - if err := r.iptablesClient.Append(tableNat, chainRTNAT, rule1...); err != nil { - return fmt.Errorf("add outbound masquerade rule: %v", err) - } - r.rules["static-nat-outbound"] = rule1 - - // Second rule for return traffic masquerade - rule2 := []string{ - "-m", "mark", "--mark", fmt.Sprintf("%#x", nbnet.PreroutingFwmarkMasqueradeReturn), - "-o", r.wgIface.Name(), - "-j", routingFinalNatJump, - } - if err := r.iptablesClient.Append(tableNat, chainRTNAT, rule2...); err != nil { - return fmt.Errorf("add return masquerade rule: %v", err) - } - r.rules["static-nat-return"] = rule2 - - return nil -} - -// addMSSClampingRules adds MSS clamping rules to prevent fragmentation for forwarded traffic. -func (r *router) addMSSClampingRules() error { - overhead := uint16(ipv4TCPHeaderSize) - if r.v6 { - overhead = ipv6TCPHeaderSize - } - mss := r.mtu - overhead - - // Add jump rule from FORWARD chain in mangle table to our custom chain - jumpRule := []string{ - "-j", chainRTMSSCLAMP, - } - if err := r.iptablesClient.Insert(tableMangle, chainFORWARD, 1, jumpRule...); err != nil { - return fmt.Errorf("add jump to MSS clamp chain: %w", err) - } - r.rules[jumpMSSClamp] = jumpRule - - ruleOut := []string{ - "-o", r.wgIface.Name(), - "-p", "tcp", - "--tcp-flags", "SYN,RST", "SYN", - "-j", "TCPMSS", - "--set-mss", fmt.Sprintf("%d", mss), - } - if err := r.iptablesClient.Append(tableMangle, chainRTMSSCLAMP, ruleOut...); err != nil { - return fmt.Errorf("add outbound MSS clamp rule: %w", err) - } - r.rules["mss-clamp-out"] = ruleOut - - return nil -} - -func (r *router) insertEstablishedRule(chain string) error { - establishedRule := getConntrackEstablished() - - err := r.iptablesClient.Insert(tableFilter, chain, 1, establishedRule...) - if err != nil { - return fmt.Errorf("failed to insert established rule: %v", err) - } - - ruleKey := "established-" + chain - r.rules[ruleKey] = establishedRule - - return nil -} - -func (r *router) addJumpRules() error { - // Jump to nat chain - natRule := []string{"-j", chainRTNAT} - if err := r.iptablesClient.Insert(tableNat, chainPOSTROUTING, 1, natRule...); err != nil { - return fmt.Errorf("add nat postrouting jump rule: %v", err) - } - r.rules[jumpNatPost] = natRule - - // Jump to mangle prerouting chain - preRule := []string{"-j", chainRTPRE} - if err := r.iptablesClient.Insert(tableMangle, chainPREROUTING, 1, preRule...); err != nil { - return fmt.Errorf("add mangle prerouting jump rule: %v", err) - } - r.rules[jumpManglePre] = preRule - - // Jump to nat prerouting chain - rdrRule := []string{"-j", chainRTRDR} - if err := r.iptablesClient.Insert(tableNat, chainPREROUTING, 1, rdrRule...); err != nil { - return fmt.Errorf("add nat prerouting jump rule: %v", err) - } - r.rules[jumpNatPre] = rdrRule - - return nil -} - -func (r *router) cleanJumpRules() error { - for _, ruleKey := range []string{jumpNatPost, jumpManglePre, jumpNatPre, jumpMSSClamp} { - if rule, exists := r.rules[ruleKey]; exists { - var table, chain string - switch ruleKey { - case jumpNatPost: - table = tableNat - chain = chainPOSTROUTING - case jumpManglePre: - table = tableMangle - chain = chainPREROUTING - case jumpNatPre: - table = tableNat - chain = chainPREROUTING - case jumpMSSClamp: - table = tableMangle - chain = chainFORWARD - default: - return fmt.Errorf("unknown jump rule: %s", ruleKey) - } - - if err := r.iptablesClient.DeleteIfExists(table, chain, rule...); err != nil { - return fmt.Errorf("delete rule from chain %s in table %s, err: %v", chain, table, err) - } - delete(r.rules, ruleKey) - } - } - return nil -} - -func (r *router) addNatRule(pair firewall.RouterPair) error { - ruleKey := firewall.GenKey(firewall.NatFormat, pair) - - if rule, exists := r.rules[ruleKey]; exists { - if err := r.iptablesClient.DeleteIfExists(tableMangle, chainRTPRE, rule...); err != nil { - return fmt.Errorf("error while removing existing marking rule for %s: %v", pair.Destination, err) - } - delete(r.rules, ruleKey) - } - - markValue := nbnet.PreroutingFwmarkMasquerade - if pair.Inverse { - markValue = nbnet.PreroutingFwmarkMasqueradeReturn - } - - rule := []string{"-i", r.wgIface.Name()} - if pair.Inverse { - rule = []string{"!", "-i", r.wgIface.Name()} - } - - rule = append(rule, - "-m", "conntrack", - "--ctstate", "NEW", - ) - sourceExp, err := r.applyNetwork("-s", pair.Source, nil) - if err != nil { - return fmt.Errorf("apply network -s: %w", err) - } - destExp, err := r.applyNetwork("-d", pair.Destination, nil) - if err != nil { - return fmt.Errorf("apply network -d: %w", err) - } - - rule = append(rule, sourceExp...) - rule = append(rule, destExp...) - rule = append(rule, - "-j", "MARK", "--set-mark", fmt.Sprintf("%#x", markValue), - ) - - // Ensure nat rules come first, so the mark can be overwritten. - // Currently overwritten by the dst-type LOCAL rules for redirected traffic. - if err := r.iptablesClient.Insert(tableMangle, chainRTPRE, 1, rule...); err != nil { - // TODO: rollback ipset counter - return fmt.Errorf("error while adding marking rule for %s: %v", pair.Destination, err) - } - - r.rules[ruleKey] = rule - - r.updateState() - return nil -} - -func (r *router) removeNatRule(pair firewall.RouterPair) error { - ruleKey := firewall.GenKey(firewall.NatFormat, pair) - - if rule, exists := r.rules[ruleKey]; exists { - if err := r.iptablesClient.DeleteIfExists(tableMangle, chainRTPRE, rule...); err != nil { - return fmt.Errorf("error while removing marking rule for %s: %v", pair.Destination, err) - } - delete(r.rules, ruleKey) - - if err := r.decrementSetCounter(rule); err != nil { - return fmt.Errorf("decrement ipset counter: %w", err) - } - } else { - log.Debugf("marking rule %s not found", ruleKey) - } - - r.updateState() - return nil -} - -func (r *router) updateState() { - if r.stateManager == nil { - return - } - - var currentState *ShutdownState - if existing := r.stateManager.GetState(currentState); existing != nil { - if existingState, ok := existing.(*ShutdownState); ok { - currentState = existingState - } - } - if currentState == nil { - currentState = &ShutdownState{} - } - - currentState.Lock() - defer currentState.Unlock() - - // Clone the rule map so the persisted state holds a private snapshot. The - // live map keeps being mutated by subsequent rule operations while the - // state manager marshals the state from its periodic-save goroutine. - // Sharing it by reference races the two and aborts the process with a - // concurrent map iteration and write. The ipset counter guards itself - // during marshaling, so it can be shared directly. - if r.v6 { - currentState.RouteRules6 = maps.Clone(r.rules) - currentState.RouteIPsetCounter6 = r.ipsetCounter - } else { - currentState.RouteRules = maps.Clone(r.rules) - currentState.RouteIPsetCounter = r.ipsetCounter - } - - if err := r.stateManager.UpdateState(currentState); err != nil { - log.Errorf("failed to update state: %v", err) - } -} - -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 - } - - toDestination := rule.TranslatedAddress.String() - switch { - case len(rule.TranslatedPort.Values) == 0: - // no translated port, use original port - case len(rule.TranslatedPort.Values) == 1: - toDestination += fmt.Sprintf(":%d", rule.TranslatedPort.Values[0]) - case rule.TranslatedPort.IsRange && len(rule.TranslatedPort.Values) == 2: - // need the "/originalport" suffix to avoid dnat port randomization - toDestination += fmt.Sprintf(":%d-%d/%d", rule.TranslatedPort.Values[0], rule.TranslatedPort.Values[1], rule.DestinationPort.Values[0]) - default: - return nil, fmt.Errorf("invalid translated port: %v", rule.TranslatedPort) - } - - proto := strings.ToLower(string(rule.Protocol)) - - rules := make(map[string]ruleInfo, 3) - - // DNAT rule - dnatRule := []string{ - "!", "-i", r.wgIface.Name(), - "-p", proto, - "-j", "DNAT", - "--to-destination", toDestination, - } - dnatRule = append(dnatRule, applyPort("--dport", &rule.DestinationPort)...) - rules[ruleKey+dnatSuffix] = ruleInfo{ - table: tableNat, - chain: chainRTRDR, - rule: dnatRule, - } - - // SNAT rule - snatRule := []string{ - "-o", r.wgIface.Name(), - "-p", proto, - "-d", rule.TranslatedAddress.String(), - "-j", "MASQUERADE", - } - snatRule = append(snatRule, applyPort("--dport", &rule.TranslatedPort)...) - rules[ruleKey+snatSuffix] = ruleInfo{ - table: tableNat, - chain: chainRTNAT, - rule: snatRule, - } - - // Forward filtering rule, if fwd policy is DROP - forwardRule := []string{ - "-o", r.wgIface.Name(), - "-p", proto, - "-d", rule.TranslatedAddress.String(), - "-j", "ACCEPT", - } - forwardRule = append(forwardRule, applyPort("--dport", &rule.TranslatedPort)...) - rules[ruleKey+fwdSuffix] = ruleInfo{ - table: tableFilter, - chain: chainRTFWDOUT, - rule: forwardRule, - } - - 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) - } - return nil, fmt.Errorf("add rule %s: %w", key, err) - } - r.rules[key] = ruleInfo.rule - } - - r.updateState() - return rule, nil -} - -func (r *router) rollbackRules(rules map[string]ruleInfo) error { - var merr *multierror.Error - for key, ruleInfo := range rules { - if err := r.iptablesClient.DeleteIfExists(ruleInfo.table, ruleInfo.chain, ruleInfo.rule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("rollback rule %s: %w", key, err)) - // On rollback error, add to rules map for next cleanup - r.rules[key] = ruleInfo.rule - } - } - if merr != nil { - r.updateState() - } - return nberrors.FormatErrorOrNil(merr) -} - -func (r *router) DeleteDNATRule(rule firewall.Rule) error { - if err := r.ipFwdState.ReleaseForwarding(); err != nil { - log.Errorf("%v", err) - } - - ruleKey := rule.ID() - - 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)) - } - 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)) - } - 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)) - } - delete(r.rules, ruleKey+fwdSuffix) - } - - r.updateState() - return nberrors.FormatErrorOrNil(merr) -} - -func (r *router) genRouteRuleSpec(params routeFilteringRuleParams, sources []netip.Prefix) ([]string, error) { - var rule []string - - sourceExp, err := r.applyNetwork("-s", params.Source, sources) - if err != nil { - return nil, fmt.Errorf("apply network -s: %w", err) - - } - destExp, err := r.applyNetwork("-d", params.Destination, nil) - if err != nil { - return nil, fmt.Errorf("apply network -d: %w", err) - } - - rule = append(rule, sourceExp...) - rule = append(rule, destExp...) - - if params.Proto != firewall.ProtocolALL { - rule = append(rule, "-p", strings.ToLower(protoForFamily(params.Proto, r.v6))) - rule = append(rule, applyPort("--sport", params.SPort)...) - rule = append(rule, applyPort("--dport", params.DPort)...) - } - - rule = append(rule, "-j", actionToStr(params.Action)) - - return rule, nil -} - -func (r *router) applyNetwork(flag string, network firewall.Network, prefixes []netip.Prefix) ([]string, error) { - direction := "src" - if flag == "-d" { - direction = "dst" - } - - if network.IsSet() { - name := r.ipsetName(network.Set.HashedName()) - if _, err := r.ipsetCounter.Increment(name, prefixes); err != nil { - return nil, fmt.Errorf("create or get ipset: %w", err) - } - - return []string{"-m", "set", matchSet, name, direction}, nil - } - if network.IsPrefix() { - return []string{flag, network.Prefix.String()}, nil - } - - // nolint:nilnil - return nil, nil -} - -func (r *router) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error { - name := r.ipsetName(set.HashedName()) - var merr *multierror.Error - for _, prefix := range prefixes { - if err := r.addPrefixToIPSet(name, prefix); err != nil { - merr = multierror.Append(merr, fmt.Errorf("add prefix to ipset: %w", err)) - } - } - if merr == nil { - log.Debugf("updated set %s with prefixes %v", name, prefixes) - } - - return nberrors.FormatErrorOrNil(merr) -} - -// AddInboundDNAT adds an inbound DNAT rule redirecting traffic from NetBird peers to local services. -func (r *router) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { - ruleID := fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort) - - if _, exists := r.rules[ruleID]; exists { - return nil - } - - dnatRule := []string{ - "-i", r.wgIface.Name(), - "-p", strings.ToLower(protoForFamily(protocol, r.v6)), - "--dport", strconv.Itoa(int(originalPort)), - "-d", localAddr.String(), - "-m", "addrtype", "--dst-type", "LOCAL", - "-j", "DNAT", - "--to-destination", ":" + strconv.Itoa(int(translatedPort)), - } - - ruleInfo := ruleInfo{ - table: tableNat, - chain: chainRTRDR, - rule: dnatRule, - } - - if err := r.iptablesClient.Append(ruleInfo.table, ruleInfo.chain, ruleInfo.rule...); err != nil { - return fmt.Errorf("add inbound DNAT rule: %w", err) - } - r.rules[ruleID] = ruleInfo.rule - - r.updateState() - return nil -} - -// RemoveInboundDNAT removes an inbound DNAT rule. -func (r *router) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { - ruleID := fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort) - - if dnatRule, exists := r.rules[ruleID]; exists { - if err := r.iptablesClient.Delete(tableNat, chainRTRDR, dnatRule...); err != nil { - return fmt.Errorf("delete inbound DNAT rule: %w", err) - } - delete(r.rules, ruleID) - } - - r.updateState() - return nil -} - -// ensureNATOutputChain lazily creates the OUTPUT NAT chain and jump rule on first use. -func (r *router) ensureNATOutputChain() error { - if _, exists := r.rules[jumpNatOutput]; exists { - return nil - } - - chainExists, err := r.iptablesClient.ChainExists(tableNat, chainNATOutput) - if err != nil { - return fmt.Errorf("check chain %s: %w", chainNATOutput, err) - } - if !chainExists { - if err := r.iptablesClient.NewChain(tableNat, chainNATOutput); err != nil { - return fmt.Errorf("create chain %s: %w", chainNATOutput, err) - } - } - - jumpRule := []string{"-j", chainNATOutput} - if err := r.iptablesClient.Insert(tableNat, "OUTPUT", 1, jumpRule...); err != nil { - if !chainExists { - if delErr := r.iptablesClient.ClearAndDeleteChain(tableNat, chainNATOutput); delErr != nil { - log.Warnf("failed to rollback chain %s: %v", chainNATOutput, delErr) - } - } - return fmt.Errorf("add OUTPUT jump rule: %w", err) - } - r.rules[jumpNatOutput] = jumpRule - - r.updateState() - return nil -} - -// AddOutputDNAT adds an OUTPUT chain DNAT rule for locally-generated traffic. -func (r *router) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { - ruleID := fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort) - - if _, exists := r.rules[ruleID]; exists { - return nil - } - - if err := r.ensureNATOutputChain(); err != nil { - return err - } - - dnatRule := []string{ - "-p", strings.ToLower(protoForFamily(protocol, localAddr.Is6())), - "--dport", strconv.Itoa(int(originalPort)), - "-d", localAddr.String(), - "-j", "DNAT", - "--to-destination", ":" + strconv.Itoa(int(translatedPort)), - } - - if err := r.iptablesClient.Append(tableNat, chainNATOutput, dnatRule...); err != nil { - return fmt.Errorf("add output DNAT rule: %w", err) - } - r.rules[ruleID] = dnatRule - - r.updateState() - return nil -} - -// RemoveOutputDNAT removes an OUTPUT chain DNAT rule. -func (r *router) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { - ruleID := fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort) - - if dnatRule, exists := r.rules[ruleID]; exists { - if err := r.iptablesClient.Delete(tableNat, chainNATOutput, dnatRule...); err != nil { - return fmt.Errorf("delete output DNAT rule: %w", err) - } - delete(r.rules, ruleID) - } - - r.updateState() - return nil -} - -func applyPort(flag string, port *firewall.Port) []string { - if port == nil { - return nil - } - - if port.IsRange && len(port.Values) == 2 { - return []string{flag, fmt.Sprintf("%d:%d", port.Values[0], port.Values[1])} - } - - if len(port.Values) > 1 { - portList := make([]string, len(port.Values)) - for i, p := range port.Values { - portList[i] = strconv.Itoa(int(p)) - } - return []string{"-m", "multiport", flag, strings.Join(portList, ",")} - } - - return []string{flag, strconv.Itoa(int(port.Values[0]))} -} - -// ipsetName returns the ipset name, suffixed with "-v6" for the v6 router -// to avoid collisions since ipsets are global in the kernel. -func (r *router) ipsetName(name string) string { - if r.v6 { - return name + "-v6" - } - return name -} - -func (r *router) createIPSet(name string) error { - opts := ipset.CreateOptions{ - Replace: true, - } - if r.v6 { - opts.Family = ipset.FamilyIPV6 - } - - if err := ipset.Create(name, ipset.TypeHashNet, opts); err != nil { - return fmt.Errorf("create ipset %s: %w", name, err) - } - - log.Debugf("created ipset %s with type hash:net", name) - return nil -} - -func (r *router) addPrefixToIPSet(name string, prefix netip.Prefix) error { - addr := prefix.Addr() - ip := addr.AsSlice() - - entry := &ipset.Entry{ - IP: ip, - CIDR: uint8(prefix.Bits()), - Replace: true, - } - - if err := ipset.Add(name, entry); err != nil { - return fmt.Errorf("add prefix to ipset %s: %w", name, err) - } - - return nil -} - -func (r *router) destroyIPSet(name string) error { - return ipset.Destroy(name) -} diff --git a/client/firewall/iptables/router_linux_test.go b/client/firewall/iptables/router_linux_test.go index 9ca6b9f7e..6c4ae9425 100644 --- a/client/firewall/iptables/router_linux_test.go +++ b/client/firewall/iptables/router_linux_test.go @@ -31,7 +31,7 @@ func TestIptablesManager_RestoreOrCreateContainers(t *testing.T) { iptablesClient, err := iptables.NewWithProtocol(iptables.ProtocolIPv4) require.NoError(t, err, "failed to init iptables client") - manager, err := newRouter(iptablesClient, ifaceMock, iface.DefaultMTU) + manager, err := newFamily(iptablesClient, ifaceMock, iface.DefaultMTU) require.NoError(t, err, "should return a valid iptables manager") require.NoError(t, manager.init(nil)) @@ -52,12 +52,12 @@ func TestIptablesManager_RestoreOrCreateContainers(t *testing.T) { // 11. MSS clamping rule for outbound traffic require.Len(t, manager.rules, 11, "should have created rules map") - exists, err := manager.iptablesClient.Exists(tableNat, chainPOSTROUTING, "-j", chainRTNAT) - require.NoError(t, err, "should be able to query the iptables %s table and %s chain", tableNat, chainPOSTROUTING) + exists, err := manager.iptablesClient.Exists(tableNat, chainPostrouting, "-j", chainRTNAT) + require.NoError(t, err, "should be able to query the iptables %s table and %s chain", tableNat, chainPostrouting) require.True(t, exists, "postrouting jump rule should exist") - exists, err = manager.iptablesClient.Exists(tableMangle, chainPREROUTING, "-j", chainRTPRE) - require.NoError(t, err, "should be able to query the iptables %s table and %s chain", tableMangle, chainPREROUTING) + exists, err = manager.iptablesClient.Exists(tableMangle, chainPrerouting, "-j", chainRTPre) + require.NoError(t, err, "should be able to query the iptables %s table and %s chain", tableMangle, chainPrerouting) require.True(t, exists, "prerouting jump rule should exist") pair := firewall.RouterPair{ @@ -84,7 +84,7 @@ func TestIptablesManager_AddNatRule(t *testing.T) { iptablesClient, err := iptables.NewWithProtocol(iptables.ProtocolIPv4) require.NoError(t, err, "failed to init iptables client") - manager, err := newRouter(iptablesClient, ifaceMock, iface.DefaultMTU) + manager, err := newFamily(iptablesClient, ifaceMock, iface.DefaultMTU) require.NoError(t, err, "shouldn't return error") require.NoError(t, manager.init(nil)) @@ -95,7 +95,7 @@ func TestIptablesManager_AddNatRule(t *testing.T) { err = manager.AddNatRule(testCase.InputPair) require.NoError(t, err, "marking rule should be inserted") - natRuleKey := firewall.GenKey(firewall.NatFormat, testCase.InputPair) + natRuleKey := testCase.InputPair.GenKey(firewall.NatFormat) markingRule := []string{ "-i", ifaceMock.Name(), "-m", "conntrack", @@ -106,8 +106,8 @@ func TestIptablesManager_AddNatRule(t *testing.T) { fmt.Sprintf("%#x", nbnet.PreroutingFwmarkMasquerade), } - exists, err := iptablesClient.Exists(tableMangle, chainRTPRE, markingRule...) - require.NoError(t, err, "should be able to query the iptables %s table and %s chain", tableMangle, chainRTPRE) + exists, err := iptablesClient.Exists(tableMangle, chainRTPre, markingRule...) + require.NoError(t, err, "should be able to query the iptables %s table and %s chain", tableMangle, chainRTPre) if testCase.InputPair.Masquerade { require.True(t, exists, "marking rule should be created") foundRule, found := manager.rules[natRuleKey] @@ -121,7 +121,7 @@ func TestIptablesManager_AddNatRule(t *testing.T) { // Check inverse rule inversePair := firewall.GetInversePair(testCase.InputPair) - inverseRuleKey := firewall.GenKey(firewall.NatFormat, inversePair) + inverseRuleKey := inversePair.GenKey(firewall.NatFormat) inverseMarkingRule := []string{ "!", "-i", ifaceMock.Name(), "-m", "conntrack", @@ -132,8 +132,8 @@ func TestIptablesManager_AddNatRule(t *testing.T) { fmt.Sprintf("%#x", nbnet.PreroutingFwmarkMasqueradeReturn), } - exists, err = iptablesClient.Exists(tableMangle, chainRTPRE, inverseMarkingRule...) - require.NoError(t, err, "should be able to query the iptables %s table and %s chain", tableMangle, chainRTPRE) + exists, err = iptablesClient.Exists(tableMangle, chainRTPre, inverseMarkingRule...) + require.NoError(t, err, "should be able to query the iptables %s table and %s chain", tableMangle, chainRTPre) if testCase.InputPair.Masquerade { require.True(t, exists, "inverse marking rule should be created") foundRule, found := manager.rules[inverseRuleKey] @@ -157,7 +157,7 @@ func TestIptablesManager_RemoveNatRule(t *testing.T) { t.Run(testCase.Name, func(t *testing.T) { iptablesClient, _ := iptables.NewWithProtocol(iptables.ProtocolIPv4) - manager, err := newRouter(iptablesClient, ifaceMock, iface.DefaultMTU) + manager, err := newFamily(iptablesClient, ifaceMock, iface.DefaultMTU) require.NoError(t, err, "shouldn't return error") require.NoError(t, manager.init(nil)) defer func() { @@ -170,7 +170,7 @@ func TestIptablesManager_RemoveNatRule(t *testing.T) { err = manager.RemoveNatRule(testCase.InputPair) require.NoError(t, err, "shouldn't return error") - natRuleKey := firewall.GenKey(firewall.NatFormat, testCase.InputPair) + natRuleKey := testCase.InputPair.GenKey(firewall.NatFormat) markingRule := []string{ "-i", ifaceMock.Name(), "-m", "conntrack", @@ -181,8 +181,8 @@ func TestIptablesManager_RemoveNatRule(t *testing.T) { fmt.Sprintf("%#x", nbnet.PreroutingFwmarkMasquerade), } - exists, err := iptablesClient.Exists(tableMangle, chainRTPRE, markingRule...) - require.NoError(t, err, "should be able to query the iptables %s table and %s chain", tableMangle, chainRTPRE) + exists, err := iptablesClient.Exists(tableMangle, chainRTPre, markingRule...) + require.NoError(t, err, "should be able to query the iptables %s table and %s chain", tableMangle, chainRTPre) require.False(t, exists, "marking rule should not exist") _, found := manager.rules[natRuleKey] @@ -190,7 +190,7 @@ func TestIptablesManager_RemoveNatRule(t *testing.T) { // Check inverse rule removal inversePair := firewall.GetInversePair(testCase.InputPair) - inverseRuleKey := firewall.GenKey(firewall.NatFormat, inversePair) + inverseRuleKey := inversePair.GenKey(firewall.NatFormat) inverseMarkingRule := []string{ "!", "-i", ifaceMock.Name(), "-m", "conntrack", @@ -201,8 +201,8 @@ func TestIptablesManager_RemoveNatRule(t *testing.T) { fmt.Sprintf("%#x", nbnet.PreroutingFwmarkMasqueradeReturn), } - exists, err = iptablesClient.Exists(tableMangle, chainRTPRE, inverseMarkingRule...) - require.NoError(t, err, "should be able to query the iptables %s table and %s chain", tableMangle, chainRTPRE) + exists, err = iptablesClient.Exists(tableMangle, chainRTPre, inverseMarkingRule...) + require.NoError(t, err, "should be able to query the iptables %s table and %s chain", tableMangle, chainRTPre) require.False(t, exists, "inverse marking rule should not exist") _, found = manager.rules[inverseRuleKey] @@ -219,13 +219,13 @@ func TestRouter_AddRouteFiltering(t *testing.T) { iptablesClient, err := iptables.NewWithProtocol(iptables.ProtocolIPv4) require.NoError(t, err, "Failed to create iptables client") - r, err := newRouter(iptablesClient, ifaceMock, iface.DefaultMTU) - require.NoError(t, err, "Failed to create router manager") + r, err := newFamily(iptablesClient, ifaceMock, iface.DefaultMTU) + require.NoError(t, err, "Failed to create family manager") require.NoError(t, r.init(nil)) defer func() { err := r.Reset() - require.NoError(t, err, "Failed to reset router") + require.NoError(t, err, "Failed to reset family") }() tests := []struct { @@ -334,62 +334,30 @@ func TestRouter_AddRouteFiltering(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ruleKey, err := r.AddRouteFiltering(nil, tt.sources, firewall.Network{Prefix: tt.destination}, tt.proto, tt.sPort, tt.dPort, tt.action) - require.NoError(t, err, "AddRouteFiltering failed") + ruleKey, err := r.AddFilterRule(nil, tt.sources, firewall.Network{Prefix: tt.destination}, tt.proto, tt.sPort, tt.dPort, tt.action) + require.NoError(t, err, "AddFilterRule failed") - // Check if the rule is in the internal map - rule, ok := r.rules[ruleKey.ID()] - assert.True(t, ok, "Rule not found in internal map") + stored, ok := r.filters[ruleKey.ID()] + require.True(t, ok, "rule not stored in filters") + t.Logf("Internal rule: %v", stored.specs) - // Log the internal rule - t.Logf("Internal rule: %v", rule) - - // Check if the rule exists in iptables - exists, err := iptablesClient.Exists(tableFilter, chainRTFWDIN, rule...) + exists, err := iptablesClient.Exists(tableFilter, chainRTFwdIn, stored.specs...) assert.NoError(t, err, "Failed to check rule existence") assert.True(t, exists, "Rule not found in iptables") - var source firewall.Network - if len(tt.sources) > 1 { - source.Set = firewall.NewPrefixSet(tt.sources) - } else if len(tt.sources) > 0 { - source.Prefix = tt.sources[0] - } - // Verify rule content - params := routeFilteringRuleParams{ - Source: source, - Destination: firewall.Network{Prefix: tt.destination}, - Proto: tt.proto, - SPort: tt.sPort, - DPort: tt.dPort, - Action: tt.action, - } - - expectedRule, err := r.genRouteRuleSpec(params, nil) - require.NoError(t, err, "Failed to generate expected rule spec") - if tt.expectSet { setName := firewall.NewPrefixSet(tt.sources).HashedName() - expectedRule, err = r.genRouteRuleSpec(params, nil) - require.NoError(t, err, "Failed to generate expected rule spec with set") - - // Check if the set was created _, exists := r.ipsetCounter.Get(setName) assert.True(t, exists, "IPSet not created") + assert.NotEmpty(t, findSets(stored.specs), "Rule should reference an ipset") } - assert.Equal(t, expectedRule, rule, "Rule content mismatch") - - // Clean up - err = r.DeleteRouteRule(ruleKey) - require.NoError(t, err, "Failed to delete rule") + require.NoError(t, r.DeleteFilterRule(ruleKey), "Failed to delete rule") }) } } func TestFindSetNameInRule(t *testing.T) { - r := &router{} - testCases := []struct { name string rule []string @@ -430,7 +398,7 @@ func TestFindSetNameInRule(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - result := r.findSets(tc.rule) + result := findSets(tc.rule) if len(result) != len(tc.expected) { t.Errorf("Expected %d sets, got %d. Sets found: %v", len(tc.expected), len(result), result) diff --git a/client/firewall/iptables/routing_linux.go b/client/firewall/iptables/routing_linux.go new file mode 100644 index 000000000..c63be4e43 --- /dev/null +++ b/client/firewall/iptables/routing_linux.go @@ -0,0 +1,273 @@ +//go:build !android + +package iptables + +import ( + "fmt" + "strings" + + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + + nberrors "github.com/netbirdio/netbird/client/errors" + firewall "github.com/netbirdio/netbird/client/firewall/manager" + nbnet "github.com/netbirdio/netbird/client/net" +) + +func (r *family) AddNatRule(pair firewall.RouterPair) error { + if r.legacyManagement { + log.Warnf("This peer is connected to a NetBird Management service with an older version. Allowing all traffic for %s", pair.Destination) + if err := r.addLegacyRouteRule(pair); err != nil { + return fmt.Errorf("add legacy routing rule: %w", err) + } + } + + if pair.Masquerade { + if err := r.addNatRule(pair); err != nil { + return fmt.Errorf("add nat rule: %w", err) + } + + if err := r.addNatRule(firewall.GetInversePair(pair)); err != nil { + return fmt.Errorf("add inverse nat rule: %w", err) + } + } + + r.updateState() + + return nil +} + +// RemoveNatRule removes an iptables rule pair from forwarding and nat chains +func (r *family) RemoveNatRule(pair firewall.RouterPair) error { + if pair.Masquerade { + if err := r.removeNatRule(pair); err != nil { + return fmt.Errorf("remove nat rule: %w", err) + } + + if err := r.removeNatRule(firewall.GetInversePair(pair)); err != nil { + return fmt.Errorf("remove inverse nat rule: %w", err) + } + } + + if err := r.removeLegacyRouteRule(pair); err != nil { + return fmt.Errorf("remove legacy routing rule: %w", err) + } + + r.updateState() + + return nil +} + +// addLegacyRouteRule adds a legacy routing rule for mgmt servers pre route acls +func (r *family) addLegacyRouteRule(pair firewall.RouterPair) error { + ruleID := pair.GenKey(firewall.ForwardingFormat) + + if err := r.removeLegacyRouteRule(pair); err != nil { + return err + } + + rule := []string{"-s", pair.Source.String(), "-d", pair.Destination.String(), "-j", "ACCEPT"} + if err := r.iptablesClient.Append(tableFilter, chainRTFwdIn, rule...); err != nil { + return fmt.Errorf("add legacy forwarding rule %s -> %s: %w", pair.Source, pair.Destination, err) + } + + r.rules[ruleID] = rule + + return nil +} + +func (r *family) removeLegacyRouteRule(pair firewall.RouterPair) error { + ruleID := pair.GenKey(firewall.ForwardingFormat) + + if rule, exists := r.rules[ruleID]; exists { + if err := r.iptablesClient.DeleteIfExists(tableFilter, chainRTFwdIn, rule...); err != nil { + return fmt.Errorf("remove legacy forwarding rule %s -> %s: %w", pair.Source, pair.Destination, err) + } + delete(r.rules, ruleID) + + if err := r.decrementSetCounter(rule); err != nil { + return fmt.Errorf("decrement ipset counter: %w", err) + } + } + + return nil +} + +// GetLegacyManagement returns the current legacy management mode +func (r *family) GetLegacyManagement() bool { + return r.legacyManagement +} + +// SetLegacyManagement sets the route manager to use legacy management mode +func (r *family) SetLegacyManagement(isLegacy bool) { + r.legacyManagement = isLegacy +} + +// RemoveAllLegacyRouteRules removes all legacy routing rules for mgmt servers pre route acls +func (r *family) RemoveAllLegacyRouteRules() error { + var merr *multierror.Error + for k, rule := range r.rules { + if !strings.HasPrefix(string(k), firewall.ForwardingFormatPrefix) { + continue + } + if err := r.iptablesClient.DeleteIfExists(tableFilter, chainRTFwdIn, rule...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove legacy forwarding rule: %w", err)) + } else { + delete(r.rules, k) + } + } + + r.updateState() + + return nberrors.FormatErrorOrNil(merr) +} + +func (r *family) addPostroutingRules() error { + // First rule for outbound masquerade + rule1 := []string{ + "-m", "mark", "--mark", fmt.Sprintf("%#x", nbnet.PreroutingFwmarkMasquerade), + "!", "-o", "lo", + "-j", "MASQUERADE", + } + if err := r.iptablesClient.Append(tableNat, chainRTNAT, rule1...); err != nil { + return fmt.Errorf("add outbound masquerade rule: %w", err) + } + r.rules["static-nat-outbound"] = rule1 + + // Second rule for return traffic masquerade + rule2 := []string{ + "-m", "mark", "--mark", fmt.Sprintf("%#x", nbnet.PreroutingFwmarkMasqueradeReturn), + "-o", r.wgIface.Name(), + "-j", "MASQUERADE", + } + if err := r.iptablesClient.Append(tableNat, chainRTNAT, rule2...); err != nil { + return fmt.Errorf("add return masquerade rule: %w", err) + } + r.rules["static-nat-return"] = rule2 + + return nil +} + +// addMSSClampingRules adds MSS clamping rules to prevent fragmentation for forwarded traffic. +func (r *family) addMSSClampingRules() error { + overhead := uint16(ipv4TCPHeaderSize) + if r.v6 { + overhead = ipv6TCPHeaderSize + } + mss := r.mtu - overhead + + // Add jump rule from FORWARD chain in mangle table to our custom chain + jumpRule := jumpRuleSpec(chainRTMSSClamp) + if err := r.iptablesClient.Insert(tableMangle, chainForward, 1, jumpRule...); err != nil { + return fmt.Errorf("add jump to MSS clamp chain: %w", err) + } + r.rules[jumpMSSClamp] = jumpRule + + ruleOut := []string{ + "-o", r.wgIface.Name(), + "-p", "tcp", + "--tcp-flags", "SYN,RST", "SYN", + "-j", "TCPMSS", + "--set-mss", fmt.Sprintf("%d", mss), + } + if err := r.iptablesClient.Append(tableMangle, chainRTMSSClamp, ruleOut...); err != nil { + return fmt.Errorf("add outbound MSS clamp rule: %w", err) + } + r.rules["mss-clamp-out"] = ruleOut + + return nil +} + +func (r *family) insertEstablishedRule(chain string) error { + establishedRule := getConntrackEstablished() + + err := r.iptablesClient.Insert(tableFilter, chain, 1, establishedRule...) + if err != nil { + return fmt.Errorf("insert established rule: %w", err) + } + + ruleID := firewall.RuleID("established-" + chain) + r.rules[ruleID] = establishedRule + + return nil +} + +func (r *family) addNatRule(pair firewall.RouterPair) (err error) { + ruleID := pair.GenKey(firewall.NatFormat) + + if rule, exists := r.rules[ruleID]; exists { + if derr := r.iptablesClient.DeleteIfExists(tableMangle, chainRTPre, rule...); derr != nil { + return fmt.Errorf("remove existing marking rule for %s: %w", pair.Destination, derr) + } + delete(r.rules, ruleID) + + // Drop the replaced spec's set references only once the new spec has + // taken its own, so a set both specs share is not destroyed and + // recreated, which would lose the prefixes UpdateSet put in it. + defer func() { + if derr := r.decrementSetCounter(rule); derr != nil && err == nil { + err = fmt.Errorf("decrement ipset counter: %w", derr) + } + }() + } + + markValue := nbnet.PreroutingFwmarkMasquerade + if pair.Inverse { + markValue = nbnet.PreroutingFwmarkMasqueradeReturn + } + + rule := []string{"-i", r.wgIface.Name()} + if pair.Inverse { + rule = []string{"!", "-i", r.wgIface.Name()} + } + + rule = append(rule, + "-m", "conntrack", + "--ctstate", "NEW", + ) + sourceExp, err := r.applyNetwork("-s", pair.Source, nil) + if err != nil { + return fmt.Errorf("apply network -s: %w", err) + } + destExp, err := r.applyNetwork("-d", pair.Destination, nil) + if err != nil { + r.dropSourceMatch(sourceExp) + return fmt.Errorf("apply network -d: %w", err) + } + + rule = append(rule, sourceExp...) + rule = append(rule, destExp...) + rule = append(rule, + "-j", "MARK", "--set-mark", fmt.Sprintf("%#x", markValue), + ) + + // Ensure nat rules come first, so the mark can be overwritten. + // Currently overwritten by the dst-type LOCAL rules for redirected traffic. + if err := r.iptablesClient.Insert(tableMangle, chainRTPre, 1, rule...); err != nil { + r.dropSourceMatch(rule) + return fmt.Errorf("add marking rule for %s: %w", pair.Destination, err) + } + + r.rules[ruleID] = rule + + return nil +} + +func (r *family) removeNatRule(pair firewall.RouterPair) error { + ruleID := pair.GenKey(firewall.NatFormat) + + if rule, exists := r.rules[ruleID]; exists { + if err := r.iptablesClient.DeleteIfExists(tableMangle, chainRTPre, rule...); err != nil { + return fmt.Errorf("remove marking rule for %s: %w", pair.Destination, err) + } + delete(r.rules, ruleID) + + if err := r.decrementSetCounter(rule); err != nil { + return fmt.Errorf("decrement ipset counter: %w", err) + } + } else { + log.Debugf("marking rule %s not found", ruleID) + } + + return nil +} diff --git a/client/firewall/iptables/rule.go b/client/firewall/iptables/rule.go index 4f4eab167..33bcbd1d2 100644 --- a/client/firewall/iptables/rule.go +++ b/client/firewall/iptables/rule.go @@ -1,18 +1,37 @@ package iptables -// Rule to handle management of rules -type Rule struct { - ruleID string - ipsetName string +import "github.com/netbirdio/netbird/client/firewall/manager" +// Rule to handle management of rules. Source set membership (when the +// rule was built against a shared hash:net ipset) is encoded in specs; +// DeleteFilterRule recovers it via findSets so the refcounter can drop +// the right reference. +type Rule struct { + id manager.RuleID specs []string mangleSpecs []string - ip string - chain string - v6 bool + // extraRules holds the rules beyond the first when the ipset + // fallback expands a multi-source rule into one rule per prefix. + extraRules []filterSpecs + chain string + v6 bool } -// GetRuleID returns the rule id -func (r *Rule) ID() string { - return r.ruleID +// filterSpecs is one installed iptables rule: its filter-table spec and +// the paired mangle redirect-mark spec (nil for route rules or when the +// mangle rule could not be added). +type filterSpecs struct { + specs []string + mangleSpecs []string +} + +// allSpecs returns the spec pairs of every iptables rule backing this +// Rule, the primary one first. +func (r *Rule) allSpecs() []filterSpecs { + return append([]filterSpecs{{specs: r.specs, mangleSpecs: r.mangleSpecs}}, r.extraRules...) +} + +// ID returns the rule id +func (r *Rule) ID() manager.RuleID { + return r.id } diff --git a/client/firewall/iptables/rulestore_linux.go b/client/firewall/iptables/rulestore_linux.go deleted file mode 100644 index a6d36540e..000000000 --- a/client/firewall/iptables/rulestore_linux.go +++ /dev/null @@ -1,127 +0,0 @@ -package iptables - -import ( - "encoding/json" - "maps" -) - -type ipList struct { - ips map[string]struct{} -} - -func newIpList(ip string) *ipList { - ips := make(map[string]struct{}) - ips[ip] = struct{}{} - - return &ipList{ - ips: ips, - } -} - -func (s *ipList) addIP(ip string) { - s.ips[ip] = struct{}{} -} - -// clone returns a deep copy of the ipList with its own ips map. -func (s *ipList) clone() *ipList { - if s == nil { - return nil - } - return &ipList{ips: maps.Clone(s.ips)} -} - -// MarshalJSON implements json.Marshaler -func (s *ipList) MarshalJSON() ([]byte, error) { - return json.Marshal(struct { - IPs map[string]struct{} `json:"ips"` - }{ - IPs: s.ips, - }) -} - -// UnmarshalJSON implements json.Unmarshaler -func (s *ipList) UnmarshalJSON(data []byte) error { - temp := struct { - IPs map[string]struct{} `json:"ips"` - }{} - if err := json.Unmarshal(data, &temp); err != nil { - return err - } - s.ips = temp.IPs - - if temp.IPs == nil { - temp.IPs = make(map[string]struct{}) - } - - return nil -} - -type ipsetStore struct { - ipsets map[string]*ipList -} - -func newIpsetStore() *ipsetStore { - return &ipsetStore{ - ipsets: make(map[string]*ipList), - } -} - -// clone returns a deep copy of the ipsetStore with its own ipsets map and -// independent ipList entries. -func (s *ipsetStore) clone() *ipsetStore { - if s == nil { - return nil - } - cloned := &ipsetStore{ipsets: make(map[string]*ipList, len(s.ipsets))} - for name, list := range s.ipsets { - cloned.ipsets[name] = list.clone() - } - return cloned -} - -func (s *ipsetStore) ipset(ipsetName string) (*ipList, bool) { - r, ok := s.ipsets[ipsetName] - return r, ok -} - -func (s *ipsetStore) addIpList(ipsetName string, list *ipList) { - s.ipsets[ipsetName] = list -} - -func (s *ipsetStore) deleteIpset(ipsetName string) { - delete(s.ipsets, ipsetName) -} - -func (s *ipsetStore) ipsetNames() []string { - names := make([]string, 0, len(s.ipsets)) - for name := range s.ipsets { - names = append(names, name) - } - return names -} - -// MarshalJSON implements json.Marshaler -func (s *ipsetStore) MarshalJSON() ([]byte, error) { - return json.Marshal(struct { - IPSets map[string]*ipList `json:"ipsets"` - }{ - IPSets: s.ipsets, - }) -} - -// UnmarshalJSON implements json.Unmarshaler -func (s *ipsetStore) UnmarshalJSON(data []byte) error { - temp := struct { - IPSets map[string]*ipList `json:"ipsets"` - }{} - if err := json.Unmarshal(data, &temp); err != nil { - return err - } - s.ipsets = temp.IPSets - - if temp.IPSets == nil { - temp.IPSets = make(map[string]*ipList) - } - - return nil -} diff --git a/client/firewall/iptables/state_linux.go b/client/firewall/iptables/state_linux.go index f4be37d01..00bf1cebd 100644 --- a/client/firewall/iptables/state_linux.go +++ b/client/firewall/iptables/state_linux.go @@ -29,17 +29,13 @@ type ShutdownState struct { InterfaceState *InterfaceState `json:"interface_state,omitempty"` - RouteRules routeRules `json:"route_rules,omitempty"` - RouteIPsetCounter *ipsetCounter `json:"route_ipset_counter,omitempty"` - - ACLEntries aclEntries `json:"acl_entries,omitempty"` - ACLIPsetStore *ipsetStore `json:"acl_ipset_store,omitempty"` - - // IPv6 counterparts + RouteRules routeRules `json:"route_rules,omitempty"` RouteRules6 routeRules `json:"route_rules_v6,omitempty"` + RouteIPsetCounter *ipsetCounter `json:"route_ipset_counter,omitempty"` RouteIPsetCounter6 *ipsetCounter `json:"route_ipset_counter_v6,omitempty"` - ACLEntries6 aclEntries `json:"acl_entries_v6,omitempty"` - ACLIPsetStore6 *ipsetStore `json:"acl_ipset_store_v6,omitempty"` + + ACLEntries aclEntries `json:"acl_entries,omitempty"` + ACLEntries6 aclEntries `json:"acl_entries_v6,omitempty"` } func (s *ShutdownState) Name() string { @@ -57,17 +53,14 @@ func (s *ShutdownState) Cleanup() error { } if s.RouteRules != nil { - ipt.router.rules = s.RouteRules + ipt.family4.rules = s.RouteRules } if s.RouteIPsetCounter != nil { - ipt.router.ipsetCounter.LoadData(s.RouteIPsetCounter) + ipt.family4.ipsetCounter.LoadData(s.RouteIPsetCounter) } if s.ACLEntries != nil { - ipt.aclMgr.entries = s.ACLEntries - } - if s.ACLIPsetStore != nil { - ipt.aclMgr.ipsetStore = s.ACLIPsetStore + ipt.family4.entries = s.ACLEntries } // Clean up v6 state even if the current run has no IPv6. @@ -79,16 +72,13 @@ func (s *ShutdownState) Cleanup() error { } if ipt.hasIPv6() { if s.RouteRules6 != nil { - ipt.router6.rules = s.RouteRules6 + ipt.family6.rules = s.RouteRules6 } if s.RouteIPsetCounter6 != nil { - ipt.router6.ipsetCounter.LoadData(s.RouteIPsetCounter6) + ipt.family6.ipsetCounter.LoadData(s.RouteIPsetCounter6) } if s.ACLEntries6 != nil { - ipt.aclMgr6.entries = s.ACLEntries6 - } - if s.ACLIPsetStore6 != nil { - ipt.aclMgr6.ipsetStore = s.ACLIPsetStore6 + ipt.family6.entries = s.ACLEntries6 } } diff --git a/client/firewall/iptables/testhelpers_linux_test.go b/client/firewall/iptables/testhelpers_linux_test.go new file mode 100644 index 000000000..fe44f7cc3 --- /dev/null +++ b/client/firewall/iptables/testhelpers_linux_test.go @@ -0,0 +1,27 @@ +//go:build privileged + +package iptables + +import ( + "fmt" + "net" + "net/netip" +) + +func pfx(ip net.IP) []netip.Prefix { + if ip == nil { + return []netip.Prefix{netip.PrefixFrom(netip.IPv4Unspecified(), 0)} + } + if ip.IsUnspecified() { + if ip.To4() != nil { + return []netip.Prefix{netip.PrefixFrom(netip.IPv4Unspecified(), 0)} + } + return []netip.Prefix{netip.PrefixFrom(netip.IPv6Unspecified(), 0)} + } + a, ok := netip.AddrFromSlice(ip) + if !ok { + panic(fmt.Sprintf("invalid IP length: %d", len(ip))) + } + a = a.Unmap() + return []netip.Prefix{netip.PrefixFrom(a, a.BitLen())} +} diff --git a/client/firewall/manager/firewall.go b/client/firewall/manager/firewall.go index 149c6db83..97a94d0f5 100644 --- a/client/firewall/manager/firewall.go +++ b/client/firewall/manager/firewall.go @@ -3,7 +3,6 @@ package manager import ( "errors" "fmt" - "net" "net/netip" "sort" @@ -16,6 +15,12 @@ import ( // method but the IPv6 firewall components were not initialized. var ErrIPv6NotInitialized = errors.New("IPv6 firewall not initialized") +// ErrNoSources is returned when AddFilterRule is called with an empty +// source list. "Match any source" must be expressed explicitly with a +// /0 prefix; an empty list is a caller error and is rejected rather +// than silently widening the rule to every source. +var ErrNoSources = errors.New("rule has no sources") + const ( ForwardingFormatPrefix = "netbird-fwd-" ForwardingFormat = "netbird-fwd-%s-%t" @@ -23,13 +28,18 @@ const ( NatFormat = "netbird-nat-%s-%t" ) +// RuleID identifies a firewall rule. It is a typed string so the +// compiler catches accidental mixing with arbitrary string keys. It is +// only an identifier and does not implement Rule. +type RuleID string + // Rule abstraction should be implemented by each firewall manager // // Each firewall type for different OS can use different type // of the properties to hold data of the created rule type Rule interface { // ID returns the rule id - ID() string + ID() RuleID } // RuleDirection is the traffic direction which a rule is applied @@ -91,6 +101,13 @@ func (d Network) IsPrefix() bool { return d.Prefix.IsValid() } +// IsZero returns true if the network designates no destination, i.e. it +// is the zero value. A zero Network is the peer-rule sentinel; a non-zero +// one carries a prefix or set destination. +func (d Network) IsZero() bool { + return !d.IsPrefix() && !d.IsSet() +} + // Manager is the high level abstraction of a firewall manager // // It declares methods which handle actions required by the @@ -98,46 +115,42 @@ func (d Network) IsPrefix() bool { type Manager interface { Init(stateManager *statemanager.Manager) error - // AllowNetbird allows netbird interface traffic - AllowNetbird() error - - // AddPeerFiltering adds a rule to the firewall + // AddFilterRule adds a packet-filtering rule to the firewall. // - // If comment argument is empty firewall manager should set - // rule ID as comment for the rule + // If destination is the zero Network, the rule applies to traffic + // inbound to this node, i.e. peer ACL semantics, installed in + // the kernel's input chain. If destination is set (prefix or + // set), the rule applies to forwarded traffic with that + // destination, route ACL semantics, installed in the forward + // chain. // - // Note: Callers should call Flush() after adding rules to ensure - // they are applied to the kernel and rule handles are refreshed. - AddPeerFiltering( + // sources must be a single address family; the caller splits mixed + // families and calls once per family. "Match any source" must be + // expressed with an explicit /0 prefix; an empty sources list is + // rejected with ErrNoSources so a zeroed list can never widen a + // rule to every source. + // + // Note: callers should call Flush() after adding rules. + AddFilterRule( id []byte, - ip net.IP, + sources []netip.Prefix, + destination Network, proto Protocol, sPort *Port, dPort *Port, action Action, - ipsetName string, - ) ([]Rule, error) + ) (Rule, error) - // DeletePeerRule from the firewall by rule definition - DeletePeerRule(rule Rule) error + // DeleteFilterRule removes a filtering rule previously added via + // AddFilterRule. The rule's own type identifies whether it lives + // in the peer (input) or route (forward) path. + DeleteFilterRule(rule Rule) error // IsServerRouteSupported returns true if the firewall supports server side routing operations IsServerRouteSupported() bool IsStateful() bool - AddRouteFiltering( - id []byte, - sources []netip.Prefix, - destination Network, - proto Protocol, - sPort, dPort *Port, - action Action, - ) (Rule, error) - - // DeleteRouteRule deletes a routing rule - DeleteRouteRule(rule Rule) error - // AddNatRule inserts a routing NAT rule AddNatRule(pair RouterPair) error @@ -185,8 +198,9 @@ type Manager interface { SetupEBPFProxyNoTrack(proxyPort, wgPort uint16) error } -func GenKey(format string, pair RouterPair) string { - return fmt.Sprintf(format, pair.ID, pair.Inverse) +// GenKey builds the rule id for this pair from the given format. +func (p RouterPair) GenKey(format string) RuleID { + return RuleID(fmt.Sprintf(format, p.ID, p.Inverse)) } // LegacyManager defines the interface for legacy management operations @@ -242,6 +256,20 @@ func MergeIPRanges(prefixes []netip.Prefix) []netip.Prefix { return merged } +// UnmapPrefix normalizes a v4-mapped v6 prefix (::ffff:a.b.c.d) to its +// plain v4 form, shifting the prefix length out of the 96-bit mapped +// range. Other prefixes are returned unchanged. Keeping prefixes +// unmapped ensures v4 rules match consistently and the match builders +// read the correct address length. +func UnmapPrefix(p netip.Prefix) netip.Prefix { + addr := p.Addr() + if !addr.Is4In6() { + return p + } + bits := max(p.Bits()-96, 0) + return netip.PrefixFrom(addr.Unmap(), bits) +} + // SortPrefixes sorts the given slice of netip.Prefix in place. // It sorts first by IP address, then by prefix length (most specific to least specific). func SortPrefixes(prefixes []netip.Prefix) { diff --git a/client/firewall/manager/forward_rule.go b/client/firewall/manager/forward_rule.go index 21a43520e..c2e9e5c60 100644 --- a/client/firewall/manager/forward_rule.go +++ b/client/firewall/manager/forward_rule.go @@ -13,13 +13,13 @@ type ForwardRule struct { TranslatedPort Port } -func (r ForwardRule) ID() string { +func (r ForwardRule) ID() RuleID { id := fmt.Sprintf("%s;%s;%s;%s", r.Protocol, r.DestinationPort.String(), r.TranslatedAddress.String(), r.TranslatedPort.String()) - return id + return RuleID(id) } func (r ForwardRule) String() string { diff --git a/client/firewall/manager/set.go b/client/firewall/manager/set.go index dda93bf47..fa55471ea 100644 --- a/client/firewall/manager/set.go +++ b/client/firewall/manager/set.go @@ -40,7 +40,7 @@ func (h Set) Comment() string { // NewPrefixSet generates a unique name for an ipset based on the given prefixes. func NewPrefixSet(prefixes []netip.Prefix) Set { - // sort for consistent naming + prefixes = slices.Clone(prefixes) SortPrefixes(prefixes) hash := sha256.New() diff --git a/client/firewall/nftables/acl_linux.go b/client/firewall/nftables/acl_linux.go deleted file mode 100644 index 9d2ea7264..000000000 --- a/client/firewall/nftables/acl_linux.go +++ /dev/null @@ -1,713 +0,0 @@ -package nftables - -import ( - "bytes" - "fmt" - "net" - "slices" - "strconv" - "strings" - "time" - - "github.com/google/nftables" - "github.com/google/nftables/binaryutil" - "github.com/google/nftables/expr" - log "github.com/sirupsen/logrus" - "golang.org/x/sys/unix" - - firewall "github.com/netbirdio/netbird/client/firewall/manager" - nbnet "github.com/netbirdio/netbird/client/net" -) - -const ( - - // rules chains contains the effective ACL rules - chainNameInputRules = "netbird-acl-input-rules" - - // filter chains contains the rules that jump to the rules chains - chainNameInputFilter = "netbird-acl-input-filter" - chainNameForwardFilter = "netbird-acl-forward-filter" - chainNameManglePrerouting = "netbird-mangle-prerouting" - chainNameManglePostrouting = "netbird-mangle-postrouting" -) - -const flushError = "flush: %w" - -type AclManager struct { - rConn *nftables.Conn - sConn *nftables.Conn - wgIface iFaceMapper - routingFwChainName string - af addrFamily - - workTable *nftables.Table - chainInputRules *nftables.Chain - chainPrerouting *nftables.Chain - - ipsetStore *ipsetStore - rules map[string]*Rule -} - -func newAclManager(table *nftables.Table, wgIface iFaceMapper, routingFwChainName string) (*AclManager, error) { - // sConn is used for creating sets and adding/removing elements from them - // it's differ then rConn (which does create new conn for each flush operation) - // and is permanent. Using same connection for both type of operations - // overloads netlink with high amount of rules ( > 10000) - sConn, err := nftables.New(nftables.AsLasting()) - if err != nil { - return nil, fmt.Errorf("create nf conn: %w", err) - } - - return &AclManager{ - rConn: &nftables.Conn{}, - sConn: sConn, - wgIface: wgIface, - workTable: table, - routingFwChainName: routingFwChainName, - af: familyForAddr(table.Family == nftables.TableFamilyIPv4), - - ipsetStore: newIpsetStore(), - rules: make(map[string]*Rule), - }, nil -} - -func (m *AclManager) init(workTable *nftables.Table) error { - m.workTable = workTable - return m.createDefaultChains() -} - -// AddPeerFiltering rule to the firewall -// -// If comment argument is empty firewall manager should set -// rule ID as comment for the rule -func (m *AclManager) AddPeerFiltering( - id []byte, - ip net.IP, - proto firewall.Protocol, - sPort *firewall.Port, - dPort *firewall.Port, - action firewall.Action, - ipsetName string, -) ([]firewall.Rule, error) { - var ipset *nftables.Set - if ipsetName != "" { - var err error - ipset, err = m.addIpToSet(ipsetName, ip) - if err != nil { - return nil, err - } - } - - newRules := make([]firewall.Rule, 0, 2) - ioRule, err := m.addIOFiltering(ip, proto, sPort, dPort, action, ipset) - if err != nil { - return nil, err - } - - newRules = append(newRules, ioRule) - return newRules, nil -} - -// DeletePeerRule from the firewall by rule definition -func (m *AclManager) DeletePeerRule(rule firewall.Rule) error { - r, ok := rule.(*Rule) - if !ok { - return fmt.Errorf("invalid rule type") - } - - if r.nftSet == nil { - if err := m.rConn.DelRule(r.nftRule); err != nil { - log.Errorf("failed to delete rule: %v", err) - } - if r.mangleRule != nil { - if err := m.rConn.DelRule(r.mangleRule); err != nil { - log.Errorf("failed to delete mangle rule: %v", err) - } - } - delete(m.rules, r.ID()) - return m.rConn.Flush() - } - - ips, ok := m.ipsetStore.ips(r.nftSet.Name) - if !ok { - if err := m.rConn.DelRule(r.nftRule); err != nil { - log.Errorf("failed to delete rule: %v", err) - } - if r.mangleRule != nil { - if err := m.rConn.DelRule(r.mangleRule); err != nil { - log.Errorf("failed to delete mangle rule: %v", err) - } - } - delete(m.rules, r.ID()) - return m.rConn.Flush() - } - - if _, ok := ips[r.ip.String()]; ok { - err := m.sConn.SetDeleteElements(r.nftSet, []nftables.SetElement{{Key: ipToBytes(r.ip, m.af)}}) - if err != nil { - log.Errorf("delete elements for set %q: %v", r.nftSet.Name, err) - } - if err := m.sConn.Flush(); err != nil { - log.Debugf("flush error of set delete element, %s", r.nftSet.Name) - return err - } - m.ipsetStore.DeleteIpFromSet(r.nftSet.Name, r.ip) - } - - // if after delete, set still contains other IPs, - // no need to delete firewall rule and we should exit here - if len(ips) > 0 { - return nil - } - - if err := m.rConn.DelRule(r.nftRule); err != nil { - log.Errorf("failed to delete rule: %v", err) - } - if r.mangleRule != nil { - if err := m.rConn.DelRule(r.mangleRule); err != nil { - log.Errorf("failed to delete mangle rule: %v", err) - } - } - - if err := m.rConn.Flush(); err != nil { - return err - } - - delete(m.rules, r.ID()) - m.ipsetStore.DeleteReferenceFromIpSet(r.nftSet.Name) - - if m.ipsetStore.HasReferenceToSet(r.nftSet.Name) { - return nil - } - - // we delete last IP from the set, that means we need to delete - // set itself and associated firewall rule too - m.rConn.FlushSet(r.nftSet) - m.rConn.DelSet(r.nftSet) - m.ipsetStore.deleteIpset(r.nftSet.Name) - return nil -} - -// createDefaultAllowRules creates default allow rules for the input and output chains -func (m *AclManager) createDefaultAllowRules() error { - expIn := []expr.Any{ - &expr.Verdict{ - Kind: expr.VerdictAccept, - }, - } - - _ = m.rConn.InsertRule(&nftables.Rule{ - Table: m.workTable, - Chain: m.chainInputRules, - Position: 0, - Exprs: expIn, - }) - - if err := m.rConn.Flush(); err != nil { - return fmt.Errorf(flushError, err) - } - return nil -} - -// Flush rule/chain/set operations from the buffer -// -// Method also get all rules after flush and refreshes handle values in the rulesets -func (m *AclManager) Flush() error { - if err := m.flushWithBackoff(); err != nil { - return err - } - - if err := m.refreshRuleHandles(m.chainInputRules, false); err != nil { - log.Errorf("failed to refresh rule handles ipv4 input chain: %v", err) - } - if err := m.refreshRuleHandles(m.chainPrerouting, true); err != nil { - log.Errorf("failed to refresh rule handles prerouting chain: %v", err) - } - - return nil -} - -func (m *AclManager) addIOFiltering( - ip net.IP, - proto firewall.Protocol, - sPort *firewall.Port, - dPort *firewall.Port, - action firewall.Action, - ipset *nftables.Set, -) (*Rule, error) { - ruleId := generatePeerRuleId(ip, proto, sPort, dPort, action, ipset) - if r, ok := m.rules[ruleId]; ok { - return &Rule{ - nftRule: r.nftRule, - mangleRule: r.mangleRule, - nftSet: r.nftSet, - ruleID: r.ruleID, - ip: ip, - }, nil - } - - var expressions []expr.Any - - if proto != firewall.ProtocolALL { - expressions = append(expressions, &expr.Payload{ - DestRegister: 1, - Base: expr.PayloadBaseNetworkHeader, - Offset: m.af.protoOffset, - Len: uint32(1), - }) - - protoData, err := m.af.protoNum(proto) - if err != nil { - return nil, fmt.Errorf("convert protocol to number: %v", err) - } - - expressions = append(expressions, &expr.Cmp{ - Register: 1, - Op: expr.CmpOpEq, - Data: []byte{protoData}, - }) - } - - rawIP := ipToBytes(ip, m.af) - // check if rawIP contains zeroed IPv4 0.0.0.0 value - // in that case not add IP match expression into the rule definition - if slices.ContainsFunc(rawIP, func(v byte) bool { return v != 0 }) { - expressions = append(expressions, - &expr.Payload{ - DestRegister: 1, - Base: expr.PayloadBaseNetworkHeader, - Offset: m.af.srcAddrOffset, - Len: m.af.addrLen, - }, - ) - // add individual IP for match if no ipset defined - if ipset == nil { - expressions = append(expressions, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: rawIP, - }, - ) - } else { - expressions = append(expressions, - &expr.Lookup{ - SourceRegister: 1, - SetName: ipset.Name, - SetID: ipset.ID, - }, - ) - } - } - - expressions = append(expressions, applyPort(sPort, true)...) - expressions = append(expressions, applyPort(dPort, false)...) - - mainExpressions := slices.Clone(expressions) - - switch action { - case firewall.ActionAccept: - mainExpressions = append(mainExpressions, &expr.Verdict{Kind: expr.VerdictAccept}) - case firewall.ActionDrop: - mainExpressions = append(mainExpressions, &expr.Verdict{Kind: expr.VerdictDrop}) - } - - userData := []byte(ruleId) - - chain := m.chainInputRules - rule := &nftables.Rule{ - Table: m.workTable, - Chain: chain, - Exprs: mainExpressions, - UserData: userData, - } - - // Insert DROP rules at the beginning, append ACCEPT rules at the end - var nftRule *nftables.Rule - if action == firewall.ActionDrop { - nftRule = m.rConn.InsertRule(rule) - } else { - nftRule = m.rConn.AddRule(rule) - } - - if err := m.rConn.Flush(); err != nil { - return nil, fmt.Errorf("flush input rule %s: %v", ruleId, err) - } - - ruleStruct := &Rule{ - nftRule: nftRule, - // best effort mangle rule - mangleRule: m.createPreroutingRule(expressions, userData), - nftSet: ipset, - ruleID: ruleId, - ip: ip, - } - m.rules[ruleId] = ruleStruct - if ipset != nil { - m.ipsetStore.AddReferenceToIpset(ipset.Name) - } - - return ruleStruct, nil -} - -func (m *AclManager) createPreroutingRule(expressions []expr.Any, userData []byte) *nftables.Rule { - if m.chainPrerouting == nil { - log.Warn("prerouting chain is not created") - return nil - } - - preroutingExprs := slices.Clone(expressions) - - // interface - preroutingExprs = append([]expr.Any{ - &expr.Meta{ - Key: expr.MetaKeyIIFNAME, - Register: 1, - }, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: ifname(m.wgIface.Name()), - }, - }, preroutingExprs...) - - // local destination and mark - preroutingExprs = append(preroutingExprs, - &expr.Fib{ - Register: 1, - ResultADDRTYPE: true, - FlagDADDR: true, - }, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: binaryutil.NativeEndian.PutUint32(unix.RTN_LOCAL), - }, - - &expr.Immediate{ - Register: 1, - Data: binaryutil.NativeEndian.PutUint32(nbnet.PreroutingFwmarkRedirected), - }, - &expr.Meta{ - Key: expr.MetaKeyMARK, - Register: 1, - SourceRegister: true, - }, - ) - - nfRule := m.rConn.AddRule(&nftables.Rule{ - Table: m.workTable, - Chain: m.chainPrerouting, - Exprs: preroutingExprs, - UserData: userData, - }) - - if err := m.rConn.Flush(); err != nil { - log.Errorf("failed to flush mangle rule %s: %v", string(userData), err) - return nil - } - - return nfRule -} - -func (m *AclManager) createDefaultChains() (err error) { - // chainNameInputRules - chain := m.createChain(chainNameInputRules) - err = m.rConn.Flush() - if err != nil { - log.Debugf("failed to create chain (%s): %s", chain.Name, err) - return fmt.Errorf(flushError, err) - } - m.chainInputRules = chain - - // netbird-acl-input-filter - // type filter hook input priority filter; policy accept; - chain = m.createFilterChainWithHook(chainNameInputFilter, nftables.ChainHookInput) - m.addJumpRule(chain, m.chainInputRules.Name, expr.MetaKeyIIFNAME) // to netbird-acl-input-rules - m.addDropExpressions(chain, expr.MetaKeyIIFNAME) - err = m.rConn.Flush() - if err != nil { - log.Debugf("failed to create chain (%s): %s", chain.Name, err) - return err - } - - // netbird-acl-forward-filter - chainFwFilter := m.createFilterChainWithHook(chainNameForwardFilter, nftables.ChainHookForward) - m.addJumpRulesToRtForward(chainFwFilter) // to netbird-rt-fwd - m.addDropExpressions(chainFwFilter, expr.MetaKeyIIFNAME) - - err = m.rConn.Flush() - if err != nil { - log.Debugf("failed to create chain (%s): %s", chainNameForwardFilter, err) - return fmt.Errorf(flushError, err) - } - - if err := m.allowRedirectedTraffic(chainFwFilter); err != nil { - log.Errorf("failed to allow redirected traffic: %s", err) - } - - return nil -} - -// Makes redirected traffic originally destined for the host itself (now subject to the forward filter) -// go through the input filter as well. This will enable e.g. Docker services to keep working by accessing the -// netbird peer IP. -func (m *AclManager) allowRedirectedTraffic(chainFwFilter *nftables.Chain) error { - // Chain is created by route manager - // TODO: move creation to a common place - m.chainPrerouting = &nftables.Chain{ - Name: chainNameManglePrerouting, - Table: m.workTable, - Type: nftables.ChainTypeFilter, - Hooknum: nftables.ChainHookPrerouting, - Priority: nftables.ChainPriorityMangle, - } - - m.addFwmarkToForward(chainFwFilter) - - if err := m.rConn.Flush(); err != nil { - return fmt.Errorf(flushError, err) - } - - return nil -} - -func (m *AclManager) addFwmarkToForward(chainFwFilter *nftables.Chain) { - m.rConn.InsertRule(&nftables.Rule{ - Table: m.workTable, - Chain: chainFwFilter, - Exprs: []expr.Any{ - &expr.Meta{ - Key: expr.MetaKeyMARK, - Register: 1, - }, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: binaryutil.NativeEndian.PutUint32(nbnet.PreroutingFwmarkRedirected), - }, - &expr.Verdict{ - Kind: expr.VerdictAccept, - }, - }, - }) -} - -func (m *AclManager) addJumpRulesToRtForward(chainFwFilter *nftables.Chain) { - expressions := []expr.Any{ - &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: ifname(m.wgIface.Name()), - }, - &expr.Verdict{ - Kind: expr.VerdictJump, - Chain: m.routingFwChainName, - }, - } - - _ = m.rConn.AddRule(&nftables.Rule{ - Table: m.workTable, - Chain: chainFwFilter, - Exprs: expressions, - }) -} - -func (m *AclManager) createChain(name string) *nftables.Chain { - chain := &nftables.Chain{ - Name: name, - Table: m.workTable, - } - - chain = m.rConn.AddChain(chain) - - insertReturnTrafficRule(m.rConn, m.workTable, chain) - - return chain -} - -func (m *AclManager) createFilterChainWithHook(name string, hookNum *nftables.ChainHook) *nftables.Chain { - polAccept := nftables.ChainPolicyAccept - chain := &nftables.Chain{ - Name: name, - Table: m.workTable, - Hooknum: hookNum, - Priority: nftables.ChainPriorityFilter, - Type: nftables.ChainTypeFilter, - Policy: &polAccept, - } - - return m.rConn.AddChain(chain) -} - -func (m *AclManager) addDropExpressions(chain *nftables.Chain, ifaceKey expr.MetaKey) []expr.Any { - expressions := []expr.Any{ - &expr.Meta{Key: ifaceKey, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: ifname(m.wgIface.Name()), - }, - &expr.Verdict{Kind: expr.VerdictDrop}, - } - _ = m.rConn.AddRule(&nftables.Rule{ - Table: m.workTable, - Chain: chain, - Exprs: expressions, - }) - return nil -} - -func (m *AclManager) addJumpRule(chain *nftables.Chain, to string, ifaceKey expr.MetaKey) { - expressions := []expr.Any{ - &expr.Meta{Key: ifaceKey, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: ifname(m.wgIface.Name()), - }, - &expr.Verdict{ - Kind: expr.VerdictJump, - Chain: to, - }, - } - - _ = m.rConn.AddRule(&nftables.Rule{ - Table: chain.Table, - Chain: chain, - Exprs: expressions, - }) -} - -func (m *AclManager) addIpToSet(ipsetName string, ip net.IP) (*nftables.Set, error) { - ipset, err := m.rConn.GetSetByName(m.workTable, ipsetName) - rawIP := ipToBytes(ip, m.af) - if err != nil { - if ipset, err = m.createSet(m.workTable, ipsetName); err != nil { - return nil, fmt.Errorf("get set name: %v", err) - } - - m.ipsetStore.newIpset(ipset.Name) - } - - if m.ipsetStore.IsIpInSet(ipset.Name, ip) { - return ipset, nil - } - - if err := m.sConn.SetAddElements(ipset, []nftables.SetElement{{Key: rawIP}}); err != nil { - return nil, fmt.Errorf("add set element for the first time: %v", err) - } - - m.ipsetStore.AddIpToSet(ipset.Name, ip) - - if err := m.sConn.Flush(); err != nil { - return nil, fmt.Errorf("flush add elements: %v", err) - } - - return ipset, nil -} - -// createSet in given table by name -func (m *AclManager) createSet(table *nftables.Table, name string) (*nftables.Set, error) { - ipset := &nftables.Set{ - Name: name, - Table: table, - Dynamic: true, - KeyType: m.af.setKeyType, - } - - if err := m.rConn.AddSet(ipset, nil); err != nil { - return nil, fmt.Errorf("create set: %v", err) - } - - if err := m.rConn.Flush(); err != nil { - return nil, fmt.Errorf("flush created set: %v", err) - } - - return ipset, nil -} - -func (m *AclManager) flushWithBackoff() (err error) { - backoff := 4 - backoffTime := 1000 * time.Millisecond - for i := 0; ; i++ { - err = m.rConn.Flush() - if err != nil { - log.Debugf("failed to flush nftables: %v", err) - if !strings.Contains(err.Error(), "busy") { - return - } - log.Error("failed to flush nftables, retrying...") - if i == backoff-1 { - return err - } - time.Sleep(backoffTime) - backoffTime *= 2 - continue - } - break - } - return -} - -func (m *AclManager) refreshRuleHandles(chain *nftables.Chain, mangle bool) error { - if m.workTable == nil || chain == nil { - return nil - } - - list, err := m.rConn.GetRules(m.workTable, chain) - if err != nil { - return err - } - - for _, rule := range list { - if len(rule.UserData) == 0 { - continue - } - split := bytes.Split(rule.UserData, []byte(" ")) - r, ok := m.rules[string(split[0])] - if ok { - if mangle { - *r.mangleRule = *rule - } else { - *r.nftRule = *rule - } - } - } - - return nil -} - -func generatePeerRuleId(ip net.IP, proto firewall.Protocol, sPort *firewall.Port, dPort *firewall.Port, action firewall.Action, ipset *nftables.Set) string { - rulesetID := ":" + string(proto) + ":" - if sPort != nil { - rulesetID += sPort.String() - } - rulesetID += ":" - if dPort != nil { - rulesetID += dPort.String() - } - rulesetID += ":" - rulesetID += strconv.Itoa(int(action)) - if ipset == nil { - return "ip:" + ip.String() + rulesetID - } - return "set:" + ipset.Name + rulesetID -} - -func ifname(n string) []byte { - b := make([]byte, 16) - copy(b, n+"\x00") - return b -} - - -// ipToBytes converts net.IP to the correct byte length for the address family. -func ipToBytes(ip net.IP, af addrFamily) []byte { - if af.addrLen == 4 { - return ip.To4() - } - return ip.To16() -} - diff --git a/client/firewall/nftables/chains_linux.go b/client/firewall/nftables/chains_linux.go new file mode 100644 index 000000000..71f0c60f2 --- /dev/null +++ b/client/firewall/nftables/chains_linux.go @@ -0,0 +1,880 @@ +//go:build !android + +package nftables + +import ( + "bytes" + "errors" + "fmt" + "slices" + "strings" + "time" + + "github.com/coreos/go-iptables/iptables" + "github.com/google/nftables" + "github.com/google/nftables/binaryutil" + "github.com/google/nftables/expr" + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + "golang.org/x/sys/unix" + + nberrors "github.com/netbirdio/netbird/client/errors" + "github.com/netbirdio/netbird/client/firewall/firewalld" + firewall "github.com/netbirdio/netbird/client/firewall/manager" + nbnet "github.com/netbirdio/netbird/client/net" +) + +func (r *family) createContainers() error { + r.chains[chainNameRoutingFw] = r.conn.AddChain(&nftables.Chain{ + Name: chainNameRoutingFw, + Table: r.workTable, + }) + + prio := *nftables.ChainPriorityNATSource - 1 + r.chains[chainNameRoutingNat] = r.conn.AddChain(&nftables.Chain{ + Name: chainNameRoutingNat, + Table: r.workTable, + Hooknum: nftables.ChainHookPostrouting, + Priority: &prio, + Type: nftables.ChainTypeNAT, + }) + + r.chains[chainNameRoutingRdr] = r.conn.AddChain(&nftables.Chain{ + Name: chainNameRoutingRdr, + Table: r.workTable, + Hooknum: nftables.ChainHookPrerouting, + Priority: nftables.ChainPriorityNATDest, + Type: nftables.ChainTypeNAT, + }) + + r.chains[chainNameManglePostrouting] = r.conn.AddChain(&nftables.Chain{ + Name: chainNameManglePostrouting, + Table: r.workTable, + Hooknum: nftables.ChainHookPostrouting, + Priority: nftables.ChainPriorityMangle, + Type: nftables.ChainTypeFilter, + }) + + r.chains[chainNameManglePrerouting] = r.conn.AddChain(&nftables.Chain{ + Name: chainNameManglePrerouting, + Table: r.workTable, + Hooknum: nftables.ChainHookPrerouting, + Priority: nftables.ChainPriorityMangle, + Type: nftables.ChainTypeFilter, + }) + + r.chains[chainNameMangleForward] = r.conn.AddChain(&nftables.Chain{ + Name: chainNameMangleForward, + Table: r.workTable, + Hooknum: nftables.ChainHookForward, + Priority: nftables.ChainPriorityMangle, + Type: nftables.ChainTypeFilter, + }) + + insertReturnTrafficRule(r.conn, r.workTable, r.chains[chainNameRoutingFw]) + + r.addPostroutingRules() + + if err := r.conn.Flush(); err != nil { + return fmt.Errorf("initialize tables: %v", err) + } + + if err := r.addMSSClampingRules(); err != nil { + log.Errorf("failed to add MSS clamping rules: %s", err) + } + + // Kernel routing opens both INPUT and FORWARD. + if err := r.openInterface(true); err != nil { + log.Errorf("failed to open interface in foreign chains: %s", err) + } + + if err := firewalld.TrustInterface(r.wgIface.Name()); err != nil { + log.Warnf("failed to trust interface in firewalld: %v", err) + } + + if err := r.refreshRulesMap(); err != nil { + log.Errorf("failed to refresh rules: %s", err) + } + + return nil +} + +// setupDataPlaneMark configures the fwmark for the data plane +func (r *family) setupDataPlaneMark() error { + if r.chains[chainNameManglePrerouting] == nil || r.chains[chainNameManglePostrouting] == nil { + return errors.New("no mangle chains found") + } + + ctNew := getCtNewExprs() + preExprs := []expr.Any{ + &expr.Meta{ + Key: expr.MetaKeyIIFNAME, + Register: 1, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: ifname(r.wgIface.Name()), + }, + } + preExprs = append(preExprs, ctNew...) + preExprs = append(preExprs, + &expr.Immediate{ + Register: 1, + Data: binaryutil.NativeEndian.PutUint32(nbnet.DataPlaneMarkIn), + }, + &expr.Ct{ + Key: expr.CtKeyMARK, + Register: 1, + SourceRegister: true, + }, + ) + + preNftRule := &nftables.Rule{ + Table: r.workTable, + Chain: r.chains[chainNameManglePrerouting], + Exprs: preExprs, + } + r.conn.AddRule(preNftRule) + + postExprs := []expr.Any{ + &expr.Meta{ + Key: expr.MetaKeyOIFNAME, + Register: 1, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: ifname(r.wgIface.Name()), + }, + } + postExprs = append(postExprs, ctNew...) + postExprs = append(postExprs, + &expr.Immediate{ + Register: 1, + Data: binaryutil.NativeEndian.PutUint32(nbnet.DataPlaneMarkOut), + }, + &expr.Ct{ + Key: expr.CtKeyMARK, + Register: 1, + SourceRegister: true, + }, + ) + + postNftRule := &nftables.Rule{ + Table: r.workTable, + Chain: r.chains[chainNameManglePostrouting], + Exprs: postExprs, + } + r.conn.AddRule(postNftRule) + + if err := r.conn.Flush(); err != nil { + return fmt.Errorf("flush: %w", err) + } + + return nil +} + +// openInterface adds passthrough accept rules for the NetBird interface to the +// kernel's filter table and external chains so they don't drop our traffic. +// includeForward also opens the FORWARD chains (kernel routing); when false only +// INPUT is opened, which is all the userspace router needs since it never +// forwards in the kernel. +func (r *family) openInterface(includeForward bool) error { + var merr *multierror.Error + + if err := r.acceptFilterTableRules(includeForward); err != nil { + merr = multierror.Append(merr, err) + } + + if err := r.acceptExternalChainsRules(includeForward); err != nil { + merr = multierror.Append(merr, fmt.Errorf("add accept rules to external chains: %w", err)) + } + + return nberrors.FormatErrorOrNil(merr) +} + +func (r *family) acceptFilterTableRules(includeForward bool) error { + if r.filterTable == nil { + return nil + } + + fw := "iptables" + + defer func() { + log.Debugf("Used %s to add accept input/forward rules", fw) + }() + + // Try iptables first and fallback to nftables if iptables is not available. + // Use the correct protocol (iptables vs ip6tables) for the address family. + ipt, err := iptables.NewWithProtocol(r.iptablesProto()) + if err != nil { + log.Warnf("Will use nftables to manipulate the filter table because iptables is not available: %v", err) + + fw = "nftables" + return r.acceptFilterRulesNftables(r.filterTable, includeForward) + } + + if err := r.acceptFilterRulesIptables(ipt, includeForward); err != nil { + log.Warnf("iptables failed (table may be incompatible), falling back to nftables: %v", err) + fw = "nftables" + return r.acceptFilterRulesNftables(r.filterTable, includeForward) + } + return nil +} + +func (r *family) acceptFilterRulesIptables(ipt *iptables.IPTables, includeForward bool) error { + var merr *multierror.Error + + if includeForward { + for _, rule := range r.getAcceptForwardRules() { + if err := ipt.Insert("filter", chainNameForward, 1, rule...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("add iptables forward rule: %v", err)) + } else { + log.Debugf("added iptables forward rule: %v", rule) + } + } + } + + inputRule := r.getAcceptInputRule() + if err := ipt.Insert("filter", chainNameInput, 1, inputRule...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("add iptables input rule: %v", err)) + } else { + log.Debugf("added iptables input rule: %v", inputRule) + } + + return nberrors.FormatErrorOrNil(merr) +} + +func (r *family) getAcceptForwardRules() [][]string { + intf := r.wgIface.Name() + return [][]string{ + {"-i", intf, "-j", "ACCEPT"}, + {"-o", intf, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "ACCEPT"}, + } +} + +func (r *family) getAcceptInputRule() []string { + return []string{"-i", r.wgIface.Name(), "-j", "ACCEPT"} +} + +// acceptFilterRulesNftables adds accept rules to the ip filter table using nftables. +// This is used when iptables is not available. +func (r *family) acceptFilterRulesNftables(table *nftables.Table, includeForward bool) error { + intf := ifname(r.wgIface.Name()) + + if includeForward { + forwardChain := &nftables.Chain{ + Name: chainNameForward, + Table: table, + Type: nftables.ChainTypeFilter, + Hooknum: nftables.ChainHookForward, + Priority: nftables.ChainPriorityFilter, + } + r.insertForwardAcceptRules(forwardChain, intf) + } + + inputChain := &nftables.Chain{ + Name: chainNameInput, + Table: table, + Type: nftables.ChainTypeFilter, + Hooknum: nftables.ChainHookInput, + Priority: nftables.ChainPriorityFilter, + } + r.insertInputAcceptRule(inputChain, intf) + + return r.conn.Flush() +} + +// acceptExternalChainsRules adds accept rules to external chains (non-netbird, non-iptables tables). +// It dynamically finds chains at call time to handle chains that may have been created after startup. +func (r *family) acceptExternalChainsRules(includeForward bool) error { + chains := r.findExternalChains() + if len(chains) == 0 { + return nil + } + + intf := ifname(r.wgIface.Name()) + for _, chain := range chains { + r.applyExternalChainAccept(chain, intf, includeForward) + } + + if err := r.conn.Flush(); err != nil { + return fmt.Errorf("flush external chain rules: %w", err) + } + return nil +} + +func (r *family) applyExternalChainAccept(chain *nftables.Chain, intf []byte, includeForward bool) { + if chain.Hooknum == nil { + log.Debugf("skipping external chain %s/%s: hooknum is nil", chain.Table.Name, chain.Name) + return + } + + log.Debugf("adding accept rules to external %s chain: %s %s/%s", + hookName(chain.Hooknum), familyName(chain.Table.Family), chain.Table.Name, chain.Name) + + switch *chain.Hooknum { + case *nftables.ChainHookForward: + if includeForward { + r.insertForwardAcceptRules(chain, intf) + } + case *nftables.ChainHookInput: + r.insertInputAcceptRule(chain, intf) + } +} + +func (r *family) insertForwardAcceptRules(chain *nftables.Chain, intf []byte) { + existing, err := r.existingNetbirdRulesInChain(chain) + if err != nil { + log.Warnf("skip forward accept rules in %s/%s: %v", chain.Table.Name, chain.Name, err) + return + } + r.insertForwardIifRule(chain, intf, existing) + r.insertForwardOifEstablishedRule(chain, intf, existing) +} + +func (r *family) insertForwardIifRule(chain *nftables.Chain, intf []byte, existing map[string]bool) { + if existing[userDataAcceptForwardRuleIif] { + return + } + r.conn.InsertRule(&nftables.Rule{ + Table: chain.Table, + Chain: chain, + Exprs: []expr.Any{ + &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: intf}, + &expr.Counter{}, + &expr.Verdict{Kind: expr.VerdictAccept}, + }, + UserData: []byte(userDataAcceptForwardRuleIif), + }) +} + +func (r *family) insertForwardOifEstablishedRule(chain *nftables.Chain, intf []byte, existing map[string]bool) { + if existing[userDataAcceptForwardRuleOif] { + return + } + exprs := []expr.Any{ + &expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: intf}, + } + r.conn.InsertRule(&nftables.Rule{ + Table: chain.Table, + Chain: chain, + Exprs: append(exprs, getEstablishedExprs(2)...), + UserData: []byte(userDataAcceptForwardRuleOif), + }) +} + +func (r *family) insertInputAcceptRule(chain *nftables.Chain, intf []byte) { + existing, err := r.existingNetbirdRulesInChain(chain) + if err != nil { + log.Warnf("skip input accept rule in %s/%s: %v", chain.Table.Name, chain.Name, err) + return + } + if existing[userDataAcceptInputRule] { + return + } + r.conn.InsertRule(&nftables.Rule{ + Table: chain.Table, + Chain: chain, + Exprs: []expr.Any{ + &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: intf}, + &expr.Counter{}, + &expr.Verdict{Kind: expr.VerdictAccept}, + }, + UserData: []byte(userDataAcceptInputRule), + }) +} + +// existingNetbirdRulesInChain returns the set of netbird-owned UserData tags present in a chain; callers must bail on error since InsertRule is additive. +func (r *family) existingNetbirdRulesInChain(chain *nftables.Chain) (map[string]bool, error) { + rules, err := r.conn.GetRules(chain.Table, chain) + if err != nil { + return nil, fmt.Errorf("list rules: %w", err) + } + present := map[string]bool{} + for _, rule := range rules { + if !isNetbirdAcceptRuleTag(rule.UserData) { + continue + } + present[string(rule.UserData)] = true + } + return present, nil +} + +func isNetbirdAcceptRuleTag(userData []byte) bool { + switch string(userData) { + case userDataAcceptForwardRuleIif, + userDataAcceptForwardRuleOif, + userDataAcceptInputRule: + return true + } + return false +} + +func (r *family) removeAcceptFilterRules() error { + var merr *multierror.Error + + if err := r.removeFilterTableRules(); err != nil { + merr = multierror.Append(merr, err) + } + + if err := r.removeExternalChainsRules(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove external chain rules: %w", err)) + } + + return nberrors.FormatErrorOrNil(merr) +} + +func (r *family) removeFilterTableRules() error { + if r.filterTable == nil { + return nil + } + + ipt, err := iptables.NewWithProtocol(r.iptablesProto()) + if err != nil { + log.Debugf("iptables not available, using nftables to remove filter rules: %v", err) + return r.removeAcceptRulesFromTable(r.filterTable) + } + + if err := r.removeAcceptFilterRulesIptables(ipt); err != nil { + log.Debugf("iptables removal failed (table may be incompatible), falling back to nftables: %v", err) + return r.removeAcceptRulesFromTable(r.filterTable) + } + return nil +} + +func (r *family) removeAcceptRulesFromTable(table *nftables.Table) error { + chains, err := r.conn.ListChainsOfTableFamily(table.Family) + if err != nil { + return fmt.Errorf("list chains: %v", err) + } + + for _, chain := range chains { + if chain.Table.Name != table.Name { + continue + } + + if chain.Name != chainNameForward && chain.Name != chainNameInput { + continue + } + + if err := r.removeAcceptRulesFromChain(table, chain); err != nil { + return err + } + } + + return r.conn.Flush() +} + +func (r *family) removeAcceptRulesFromChain(table *nftables.Table, chain *nftables.Chain) error { + rules, err := r.conn.GetRules(table, chain) + if err != nil { + return fmt.Errorf("get rules from %s/%s: %v", table.Name, chain.Name, err) + } + + for _, rule := range rules { + if bytes.Equal(rule.UserData, []byte(userDataAcceptForwardRuleIif)) || + bytes.Equal(rule.UserData, []byte(userDataAcceptForwardRuleOif)) || + bytes.Equal(rule.UserData, []byte(userDataAcceptInputRule)) { + if err := r.conn.DelRule(rule); err != nil { + return fmt.Errorf("delete rule from %s/%s: %v", table.Name, chain.Name, err) + } + } + } + return nil +} + +// removeExternalChainsRules removes our accept rules from all external chains. +// This is deterministic - it scans for chains at removal time rather than relying on saved state, +// ensuring cleanup works even after a crash or if chains changed. +func (r *family) removeExternalChainsRules() error { + chains := r.findExternalChains() + if len(chains) == 0 { + return nil + } + + var merr *multierror.Error + for _, chain := range chains { + if err := r.removeAcceptRulesFromChain(chain.Table, chain); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove rules from external chain %s/%s: %w", chain.Table.Name, chain.Name, err)) + continue + } + if err := r.conn.Flush(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("flush external chain %s/%s: %w", chain.Table.Name, chain.Name, err)) + } + } + + return nberrors.FormatErrorOrNil(merr) +} + +// findExternalChains scans for chains from non-netbird tables that have FORWARD or INPUT hooks. +// This is used both at startup (to know where to add rules) and at cleanup (to ensure deterministic removal). +func (r *family) findExternalChains() []*nftables.Chain { + var chains []*nftables.Chain + + families := []nftables.TableFamily{r.af.tableFamily, nftables.TableFamilyINet} + + for _, family := range families { + allChains, err := r.conn.ListChainsOfTableFamily(family) + if err != nil { + log.Debugf("list chains for family %d: %v", family, err) + continue + } + + for _, chain := range allChains { + if r.isExternalChain(chain) { + chains = append(chains, chain) + } + } + } + + return chains +} + +func (r *family) isExternalChain(chain *nftables.Chain) bool { + if r.workTable != nil && chain.Table.Name == r.workTable.Name { + return false + } + + // Skip firewalld-owned chains. Firewalld creates its chains with the + // NFT_CHAIN_OWNER flag, so inserting rules into them returns EPERM. + // We delegate acceptance to firewalld by trusting the interface instead. + if chain.Table.Name == firewalldTableName { + return false + } + + // Skip iptables/ip6tables-managed tables (adding nft-native rules breaks iptables-save compat) + if (chain.Table.Family == nftables.TableFamilyIPv4 || chain.Table.Family == nftables.TableFamilyIPv6) && isIptablesTable(chain.Table.Name) { + return false + } + + if chain.Type != nftables.ChainTypeFilter { + return false + } + + if chain.Hooknum == nil { + return false + } + + return *chain.Hooknum == *nftables.ChainHookForward || *chain.Hooknum == *nftables.ChainHookInput +} + +func isIptablesTable(name string) bool { + switch name { + case tableNameFilter, tableNat, tableMangle, tableRaw, tableSecurity: + return true + } + return false +} + +func (r *family) removeAcceptFilterRulesIptables(ipt *iptables.IPTables) error { + var merr *multierror.Error + + for _, rule := range r.getAcceptForwardRules() { + if err := ipt.DeleteIfExists("filter", chainNameForward, rule...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove iptables forward rule: %v", err)) + } + } + + inputRule := r.getAcceptInputRule() + if err := ipt.DeleteIfExists("filter", chainNameInput, inputRule...); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove iptables input rule: %v", err)) + } + + return nberrors.FormatErrorOrNil(merr) +} + +// Flush rule/chain/set operations from the buffer +// +// Method also get all rules after flush and refreshes handle values in the rulesets +func (r *family) Flush() error { + if err := r.flushWithBackoff(); err != nil { + return err + } + + if err := r.refreshRuleHandles(r.chainInputRules, false); err != nil { + log.Errorf("failed to refresh rule handles ipv4 input chain: %v", err) + } + if err := r.refreshRuleHandles(r.chainPrerouting, true); err != nil { + log.Errorf("failed to refresh rule handles prerouting chain: %v", err) + } + + return nil +} + +// queuePreroutingRule builds the prerouting mangle rule that marks +// redirected traffic and queues it on the connection without flushing, +// so the caller can commit it in the same transaction as the rule it +// pairs with. Returns nil when the prerouting chain is absent, in which +// case nothing is queued. +func (r *family) queuePreroutingRule(expressions []expr.Any, userData []byte) *nftables.Rule { + if r.chainPrerouting == nil { + log.Warn("prerouting chain is not created") + return nil + } + + preroutingExprs := slices.Clone(expressions) + + // interface + preroutingExprs = append([]expr.Any{ + &expr.Meta{ + Key: expr.MetaKeyIIFNAME, + Register: 1, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: ifname(r.wgIface.Name()), + }, + }, preroutingExprs...) + + // local destination and mark + preroutingExprs = append(preroutingExprs, + &expr.Fib{ + Register: 1, + ResultADDRTYPE: true, + FlagDADDR: true, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: binaryutil.NativeEndian.PutUint32(unix.RTN_LOCAL), + }, + + &expr.Immediate{ + Register: 1, + Data: binaryutil.NativeEndian.PutUint32(nbnet.PreroutingFwmarkRedirected), + }, + &expr.Meta{ + Key: expr.MetaKeyMARK, + Register: 1, + SourceRegister: true, + }, + ) + + return r.conn.AddRule(&nftables.Rule{ + Table: r.workTable, + Chain: r.chainPrerouting, + Exprs: preroutingExprs, + UserData: userData, + }) +} + +func (r *family) createDefaultChains() (err error) { + // chainNameInputRules + chain := r.createChain(chainNameInputRules) + err = r.conn.Flush() + if err != nil { + log.Debugf("failed to create chain (%s): %s", chain.Name, err) + return fmt.Errorf(flushError, err) + } + r.chainInputRules = chain + + // netbird-acl-input-filter + // type filter hook input priority filter; policy accept; + chain = r.createFilterChainWithHook(chainNameInputFilter, nftables.ChainHookInput) + r.addJumpRule(chain, r.chainInputRules.Name, expr.MetaKeyIIFNAME) // to netbird-acl-input-rules + r.addDropExpressions(chain, expr.MetaKeyIIFNAME) + err = r.conn.Flush() + if err != nil { + log.Debugf("failed to create chain (%s): %s", chain.Name, err) + return err + } + + // netbird-acl-forward-filter + chainFwFilter := r.createFilterChainWithHook(chainNameForwardFilter, nftables.ChainHookForward) + r.addJumpRulesToRtForward(chainFwFilter) // to netbird-rt-fwd + r.addDropExpressions(chainFwFilter, expr.MetaKeyIIFNAME) + + err = r.conn.Flush() + if err != nil { + log.Debugf("failed to create chain (%s): %s", chainNameForwardFilter, err) + return fmt.Errorf(flushError, err) + } + + if err := r.allowRedirectedTraffic(chainFwFilter); err != nil { + log.Errorf("failed to allow redirected traffic: %s", err) + } + + return nil +} + +// Makes redirected traffic originally destined for the host itself (now subject to the forward filter) +// go through the input filter as well. This will enable e.g. Docker services to keep working by accessing the +// netbird peer IP. +func (r *family) allowRedirectedTraffic(chainFwFilter *nftables.Chain) error { + r.chainPrerouting = r.chains[chainNameManglePrerouting] + + r.addFwmarkToForward(chainFwFilter) + + if err := r.conn.Flush(); err != nil { + return fmt.Errorf(flushError, err) + } + + return nil +} + +func (r *family) addFwmarkToForward(chainFwFilter *nftables.Chain) { + r.conn.InsertRule(&nftables.Rule{ + Table: r.workTable, + Chain: chainFwFilter, + Exprs: []expr.Any{ + &expr.Meta{ + Key: expr.MetaKeyMARK, + Register: 1, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: binaryutil.NativeEndian.PutUint32(nbnet.PreroutingFwmarkRedirected), + }, + &expr.Verdict{ + Kind: expr.VerdictAccept, + }, + }, + }) +} + +func (r *family) addJumpRulesToRtForward(chainFwFilter *nftables.Chain) { + expressions := []expr.Any{ + &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: ifname(r.wgIface.Name()), + }, + &expr.Verdict{ + Kind: expr.VerdictJump, + Chain: r.routingFwChainName, + }, + } + + _ = r.conn.AddRule(&nftables.Rule{ + Table: r.workTable, + Chain: chainFwFilter, + Exprs: expressions, + }) +} + +func (r *family) createChain(name string) *nftables.Chain { + chain := &nftables.Chain{ + Name: name, + Table: r.workTable, + } + + chain = r.conn.AddChain(chain) + + insertReturnTrafficRule(r.conn, r.workTable, chain) + + return chain +} + +func (r *family) createFilterChainWithHook(name string, hookNum *nftables.ChainHook) *nftables.Chain { + polAccept := nftables.ChainPolicyAccept + chain := &nftables.Chain{ + Name: name, + Table: r.workTable, + Hooknum: hookNum, + Priority: nftables.ChainPriorityFilter, + Type: nftables.ChainTypeFilter, + Policy: &polAccept, + } + + return r.conn.AddChain(chain) +} + +func (r *family) addDropExpressions(chain *nftables.Chain, ifaceKey expr.MetaKey) []expr.Any { + expressions := []expr.Any{ + &expr.Meta{Key: ifaceKey, Register: 1}, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: ifname(r.wgIface.Name()), + }, + &expr.Verdict{Kind: expr.VerdictDrop}, + } + _ = r.conn.AddRule(&nftables.Rule{ + Table: r.workTable, + Chain: chain, + Exprs: expressions, + }) + return nil +} + +func (r *family) addJumpRule(chain *nftables.Chain, to string, ifaceKey expr.MetaKey) { + expressions := []expr.Any{ + &expr.Meta{Key: ifaceKey, Register: 1}, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: ifname(r.wgIface.Name()), + }, + &expr.Verdict{ + Kind: expr.VerdictJump, + Chain: to, + }, + } + + _ = r.conn.AddRule(&nftables.Rule{ + Table: chain.Table, + Chain: chain, + Exprs: expressions, + }) +} + +func (r *family) flushWithBackoff() (err error) { + backoff := 4 + backoffTime := 1000 * time.Millisecond + for i := 0; ; i++ { + err = r.conn.Flush() + if err != nil { + log.Debugf("failed to flush nftables: %v", err) + if !strings.Contains(err.Error(), "busy") { + return + } + log.Error("failed to flush nftables, retrying...") + if i == backoff-1 { + return err + } + time.Sleep(backoffTime) + backoffTime *= 2 + continue + } + break + } + return +} + +func (r *family) refreshRuleHandles(chain *nftables.Chain, mangle bool) error { + if r.workTable == nil || chain == nil { + return nil + } + + list, err := r.conn.GetRules(r.workTable, chain) + if err != nil { + return err + } + + for _, rule := range list { + if len(rule.UserData) == 0 { + continue + } + pr, ok := r.filters[firewall.RuleID(rule.UserData)] + if !ok { + continue + } + if mangle { + if pr.mangleRule != nil { + *pr.mangleRule = *rule + } + } else { + *pr.nftRule = *rule + } + } + + return nil +} diff --git a/client/firewall/nftables/dnat_linux.go b/client/firewall/nftables/dnat_linux.go new file mode 100644 index 000000000..8eae694a2 --- /dev/null +++ b/client/firewall/nftables/dnat_linux.go @@ -0,0 +1,573 @@ +//go:build !android + +package nftables + +import ( + "fmt" + "net/netip" + + "github.com/google/nftables" + "github.com/google/nftables/binaryutil" + "github.com/google/nftables/expr" + "github.com/google/nftables/xt" + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + + nberrors "github.com/netbirdio/netbird/client/errors" + firewall "github.com/netbirdio/netbird/client/firewall/manager" +) + +func (r *family) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) { + ruleID := rule.ID() + if _, exists := r.rules[ruleID+dnatSuffix]; exists { + return rule, nil + } + + protoNum, err := r.af.protoNum(rule.Protocol) + if err != nil { + 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. + if err := r.ipFwdState.RequestForwarding(r.isV6()); err != nil { + return nil, fmt.Errorf("enable forwarding: %w", err) + } + + if err := r.addDnatRedirect(rule, protoNum, ruleID); err != nil { + r.releaseForwarding() + return nil, err + } + + if err := r.addDnatMasq(rule, protoNum, ruleID); err != nil { + r.releaseForwarding() + delete(r.rules, ruleID+dnatSuffix) + return nil, err + } + + // Unlike iptables, there's no point in adding "out" rules in the forward chain here as our policy is ACCEPT. + // To overcome DROP policies in other chains, we'd have to add rules to the chains there. + // We also cannot just add "oif accept" there and filter in our own table as we don't know what is supposed to be allowed. + // TODO: find chains with drop policies and add rules there + + if err := r.conn.Flush(); err != nil { + r.releaseForwarding() + delete(r.rules, ruleID+dnatSuffix) + delete(r.rules, ruleID+snatSuffix) + return nil, fmt.Errorf("flush rules: %w", err) + } + + return &rule, nil +} + +func (r *family) addDnatRedirect(rule firewall.ForwardRule, protoNum uint8, ruleID firewall.RuleID) error { + dnatExprs := []expr.Any{ + &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, + &expr.Cmp{ + Op: expr.CmpOpNeq, + Register: 1, + Data: ifname(r.wgIface.Name()), + }, + &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: []byte{protoNum}, + }, + &expr.Payload{ + DestRegister: 1, + Base: expr.PayloadBaseTransportHeader, + Offset: 2, + Len: 2, + }, + } + portExprs, err := r.applyPort(&rule.DestinationPort, false) + if err != nil { + return fmt.Errorf("apply destination port: %w", err) + } + dnatExprs = append(dnatExprs, portExprs...) + + // shifted translated port is not supported in nftables, so we hand this over to xtables + if rule.TranslatedPort.IsRange && len(rule.TranslatedPort.Values) == 2 { + if rule.TranslatedPort.Values[0] != rule.DestinationPort.Values[0] || + rule.TranslatedPort.Values[1] != rule.DestinationPort.Values[1] { + return r.addXTablesRedirect(dnatExprs, ruleID, rule) + } + } + + additionalExprs, regProtoMin, regProtoMax, err := r.handleTranslatedPort(rule) + if err != nil { + return err + } + dnatExprs = append(dnatExprs, additionalExprs...) + + dnatExprs = append(dnatExprs, + &expr.NAT{ + Type: expr.NATTypeDestNAT, + Family: uint32(r.af.tableFamily), + RegAddrMin: 1, + RegProtoMin: regProtoMin, + RegProtoMax: regProtoMax, + }, + ) + + dnatRule := &nftables.Rule{ + Table: r.workTable, + Chain: r.chains[chainNameRoutingRdr], + Exprs: dnatExprs, + UserData: []byte(ruleID + dnatSuffix), + } + r.conn.AddRule(dnatRule) + r.rules[ruleID+dnatSuffix] = dnatRule + + return nil +} + +func (r *family) handleTranslatedPort(rule firewall.ForwardRule) ([]expr.Any, uint32, uint32, error) { + switch { + case rule.TranslatedPort.IsRange && len(rule.TranslatedPort.Values) == 2: + return r.handlePortRange(rule) + case len(rule.TranslatedPort.Values) == 0: + return r.handleAddressOnly(rule) + case len(rule.TranslatedPort.Values) == 1: + return r.handleSinglePort(rule) + default: + return nil, 0, 0, fmt.Errorf("invalid translated port: %v", rule.TranslatedPort) + } +} + +func (r *family) handlePortRange(rule firewall.ForwardRule) ([]expr.Any, uint32, uint32, error) { + exprs := []expr.Any{ + &expr.Immediate{ + Register: 1, + Data: rule.TranslatedAddress.AsSlice(), + }, + &expr.Immediate{ + Register: 2, + Data: binaryutil.BigEndian.PutUint16(rule.TranslatedPort.Values[0]), + }, + &expr.Immediate{ + Register: 3, + Data: binaryutil.BigEndian.PutUint16(rule.TranslatedPort.Values[1]), + }, + } + return exprs, 2, 3, nil +} + +func (r *family) handleAddressOnly(rule firewall.ForwardRule) ([]expr.Any, uint32, uint32, error) { + exprs := []expr.Any{ + &expr.Immediate{ + Register: 1, + Data: rule.TranslatedAddress.AsSlice(), + }, + } + return exprs, 0, 0, nil +} + +func (r *family) handleSinglePort(rule firewall.ForwardRule) ([]expr.Any, uint32, uint32, error) { + exprs := []expr.Any{ + &expr.Immediate{ + Register: 1, + Data: rule.TranslatedAddress.AsSlice(), + }, + &expr.Immediate{ + Register: 2, + Data: binaryutil.BigEndian.PutUint16(rule.TranslatedPort.Values[0]), + }, + } + return exprs, 2, 0, nil +} + +func (r *family) addXTablesRedirect(dnatExprs []expr.Any, ruleID firewall.RuleID, rule firewall.ForwardRule) error { + dnatExprs = append(dnatExprs, + &expr.Counter{}, + &expr.Target{ + Name: "DNAT", + Rev: 2, + Info: &xt.NatRange2{ + NatRange: xt.NatRange{ + Flags: uint(xt.NatRangeMapIPs | xt.NatRangeProtoSpecified | xt.NatRangeProtoOffset), + MinIP: rule.TranslatedAddress.AsSlice(), + MaxIP: rule.TranslatedAddress.AsSlice(), + MinPort: rule.TranslatedPort.Values[0], + MaxPort: rule.TranslatedPort.Values[1], + }, + BasePort: rule.DestinationPort.Values[0], + }, + }, + ) + + natTable := &nftables.Table{ + Name: tableNat, + Family: r.af.tableFamily, + } + dnatRule := &nftables.Rule{ + Table: natTable, + Chain: &nftables.Chain{ + Name: chainNameNatPrerouting, + Table: natTable, + Type: nftables.ChainTypeNAT, + Hooknum: nftables.ChainHookPrerouting, + Priority: nftables.ChainPriorityNATDest, + }, + Exprs: dnatExprs, + UserData: []byte(ruleID + dnatSuffix), + } + r.conn.AddRule(dnatRule) + r.rules[ruleID+dnatSuffix] = dnatRule + + return nil +} + +func (r *family) addDnatMasq(rule firewall.ForwardRule, protoNum uint8, ruleID firewall.RuleID) error { + portExprs, err := r.applyPort(&rule.TranslatedPort, false) + if err != nil { + return fmt.Errorf("apply translated port: %w", err) + } + + masqExprs := []expr.Any{ + &expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1}, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: ifname(r.wgIface.Name()), + }, + &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: []byte{protoNum}, + }, + &expr.Payload{ + DestRegister: 1, + Base: expr.PayloadBaseNetworkHeader, + Offset: r.af.dstAddrOffset, + Len: r.af.addrLen, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: rule.TranslatedAddress.AsSlice(), + }, + } + + masqExprs = append(masqExprs, portExprs...) + masqExprs = append(masqExprs, &expr.Masq{}) + + masqRule := &nftables.Rule{ + Table: r.workTable, + Chain: r.chains[chainNameRoutingNat], + Exprs: masqExprs, + UserData: []byte(ruleID + snatSuffix), + } + r.conn.AddRule(masqRule) + r.rules[ruleID+snatSuffix] = masqRule + + return nil +} + +func (r *family) DeleteDNATRule(rule firewall.Rule) error { + ruleID := rule.ID() + + if err := r.refreshRulesMap(); err != nil { + return fmt.Errorf(refreshRulesMapError, err) + } + + var merr *multierror.Error + var needsFlush bool + var found bool + + if dnatRule, exists := r.rules[ruleID+dnatSuffix]; exists { + found = true + if dnatRule.Handle == 0 { + log.Warnf("dnat rule %s has no handle, removing stale entry", ruleID+dnatSuffix) + delete(r.rules, ruleID+dnatSuffix) + } else if err := r.conn.DelRule(dnatRule); err != nil { + merr = multierror.Append(merr, fmt.Errorf("delete dnat rule: %w", err)) + } else { + needsFlush = true + } + } + + if masqRule, exists := r.rules[ruleID+snatSuffix]; exists { + found = true + if masqRule.Handle == 0 { + log.Warnf("snat rule %s has no handle, removing stale entry", ruleID+snatSuffix) + delete(r.rules, ruleID+snatSuffix) + } else if err := r.conn.DelRule(masqRule); err != nil { + merr = multierror.Append(merr, fmt.Errorf("delete snat rule: %w", err)) + } else { + needsFlush = true + } + } + + if needsFlush { + if err := r.conn.Flush(); err != nil { + merr = multierror.Append(merr, fmt.Errorf(flushError, err)) + } + } + + if merr != nil { + return nberrors.FormatErrorOrNil(merr) + } + + delete(r.rules, ruleID+dnatSuffix) + delete(r.rules, ruleID+snatSuffix) + + // Release once, only if the rule was present and removed. + if found { + r.releaseForwarding() + } + + return nil +} + +// releaseForwarding drops one IP forwarding reference, logging any error. +func (r *family) releaseForwarding() { + if err := r.ipFwdState.ReleaseForwarding(r.isV6()); err != nil { + log.Errorf("release IP forwarding: %v", err) + } +} + +// isV6 reports whether this family handles the IPv6 table. +func (r *family) isV6() bool { + return r.af.tableFamily == nftables.TableFamilyIPv6 +} + +func (r *family) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { + ruleID := firewall.RuleID(fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort)) + + if _, exists := r.rules[ruleID]; exists { + return nil + } + + protoNum, err := r.af.protoNum(protocol) + if err != nil { + return fmt.Errorf("convert protocol to number: %w", err) + } + + exprs := []expr.Any{ + &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: ifname(r.wgIface.Name()), + }, + &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 2}, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 2, + Data: []byte{protoNum}, + }, + &expr.Payload{ + DestRegister: 3, + Base: expr.PayloadBaseTransportHeader, + Offset: 2, + Len: 2, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 3, + Data: binaryutil.BigEndian.PutUint16(originalPort), + }, + } + + bits := 32 + if localAddr.Is6() { + bits = 128 + } + exprs = append(exprs, prefixMatchExprs(r.af, netip.PrefixFrom(localAddr, bits), false)...) + + exprs = append(exprs, + &expr.Immediate{ + Register: 1, + Data: localAddr.AsSlice(), + }, + &expr.Immediate{ + Register: 2, + Data: binaryutil.BigEndian.PutUint16(translatedPort), + }, + &expr.NAT{ + Type: expr.NATTypeDestNAT, + Family: uint32(r.af.tableFamily), + RegAddrMin: 1, + RegProtoMin: 2, + RegProtoMax: 0, + }, + ) + + dnatRule := &nftables.Rule{ + Table: r.workTable, + Chain: r.chains[chainNameRoutingRdr], + Exprs: exprs, + UserData: []byte(ruleID), + } + r.conn.AddRule(dnatRule) + + if err := r.conn.Flush(); err != nil { + return fmt.Errorf("add inbound DNAT rule: %w", err) + } + + r.rules[ruleID] = dnatRule + + return nil +} + +// RemoveInboundDNAT removes an inbound DNAT rule. +func (r *family) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { + if err := r.refreshRulesMap(); err != nil { + return fmt.Errorf(refreshRulesMapError, err) + } + + ruleID := firewall.RuleID(fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort)) + + rule, exists := r.rules[ruleID] + if !exists { + return nil + } + + if rule.Handle == 0 { + log.Warnf("inbound DNAT rule %s has no handle, removing stale entry", ruleID) + delete(r.rules, ruleID) + return nil + } + + if err := r.conn.DelRule(rule); err != nil { + return fmt.Errorf("delete inbound DNAT rule %s: %w", ruleID, err) + } + if err := r.conn.Flush(); err != nil { + return fmt.Errorf("flush delete inbound DNAT rule: %w", err) + } + delete(r.rules, ruleID) + + return nil +} + +// ensureNATOutputChain lazily creates the OUTPUT NAT chain on first use. +func (r *family) ensureNATOutputChain() error { + if _, exists := r.chains[chainNameNATOutput]; exists { + return nil + } + + r.chains[chainNameNATOutput] = r.conn.AddChain(&nftables.Chain{ + Name: chainNameNATOutput, + Table: r.workTable, + Hooknum: nftables.ChainHookOutput, + Priority: nftables.ChainPriorityNATDest, + Type: nftables.ChainTypeNAT, + }) + + if err := r.conn.Flush(); err != nil { + delete(r.chains, chainNameNATOutput) + return fmt.Errorf("create NAT output chain: %w", err) + } + return nil +} + +// AddOutputDNAT adds an OUTPUT chain DNAT rule for locally-generated traffic. +func (r *family) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { + ruleID := firewall.RuleID(fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort)) + + if _, exists := r.rules[ruleID]; exists { + return nil + } + + if err := r.ensureNATOutputChain(); err != nil { + return err + } + + protoNum, err := r.af.protoNum(protocol) + if err != nil { + return fmt.Errorf("convert protocol to number: %w", err) + } + + exprs := []expr.Any{ + &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: []byte{protoNum}, + }, + &expr.Payload{ + DestRegister: 2, + Base: expr.PayloadBaseTransportHeader, + Offset: 2, + Len: 2, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 2, + Data: binaryutil.BigEndian.PutUint16(originalPort), + }, + } + + bits := 32 + if localAddr.Is6() { + bits = 128 + } + exprs = append(exprs, prefixMatchExprs(r.af, netip.PrefixFrom(localAddr, bits), false)...) + + exprs = append(exprs, + &expr.Immediate{ + Register: 1, + Data: localAddr.AsSlice(), + }, + &expr.Immediate{ + Register: 2, + Data: binaryutil.BigEndian.PutUint16(translatedPort), + }, + &expr.NAT{ + Type: expr.NATTypeDestNAT, + Family: uint32(r.af.tableFamily), + RegAddrMin: 1, + RegProtoMin: 2, + }, + ) + + dnatRule := &nftables.Rule{ + Table: r.workTable, + Chain: r.chains[chainNameNATOutput], + Exprs: exprs, + UserData: []byte(ruleID), + } + r.conn.AddRule(dnatRule) + + if err := r.conn.Flush(); err != nil { + return fmt.Errorf("add output DNAT rule: %w", err) + } + + r.rules[ruleID] = dnatRule + + return nil +} + +// RemoveOutputDNAT removes an OUTPUT chain DNAT rule. +func (r *family) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { + if err := r.refreshRulesMap(); err != nil { + return fmt.Errorf(refreshRulesMapError, err) + } + + ruleID := firewall.RuleID(fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort)) + + rule, exists := r.rules[ruleID] + if !exists { + return nil + } + + if rule.Handle == 0 { + log.Warnf("output DNAT rule %s has no handle, removing stale entry", ruleID) + delete(r.rules, ruleID) + return nil + } + + if err := r.conn.DelRule(rule); err != nil { + return fmt.Errorf("delete output DNAT rule %s: %w", ruleID, err) + } + if err := r.conn.Flush(); err != nil { + return fmt.Errorf("flush delete output DNAT rule: %w", err) + } + delete(r.rules, ruleID) + + return nil +} diff --git a/client/firewall/nftables/dnat_refcount_linux_test.go b/client/firewall/nftables/dnat_refcount_linux_test.go new file mode 100644 index 000000000..cdc24e77f --- /dev/null +++ b/client/firewall/nftables/dnat_refcount_linux_test.go @@ -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.family4.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.family6, "v6 family") + require.Same(t, m.family4.ipFwdState, m.family6.ipFwdState, "shared state") + state := m.family4.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.family4.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.family4.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.family4.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.family4.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.family4.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") +} diff --git a/client/firewall/nftables/family_linux.go b/client/firewall/nftables/family_linux.go new file mode 100644 index 000000000..7a5df3ed7 --- /dev/null +++ b/client/firewall/nftables/family_linux.go @@ -0,0 +1,249 @@ +//go:build !android + +package nftables + +import ( + "fmt" + "net/netip" + + "github.com/coreos/go-iptables/iptables" + "github.com/google/nftables" + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + + nberrors "github.com/netbirdio/netbird/client/errors" + "github.com/netbirdio/netbird/client/firewall/firewalld" + firewall "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/internal/routemanager/ipfwdstate" + "github.com/netbirdio/netbird/client/internal/routemanager/refcounter" +) + +const ( + tableNat = "nat" + tableMangle = "mangle" + tableRaw = "raw" + tableSecurity = "security" + + chainNameNatPrerouting = "PREROUTING" + chainNameRoutingFw = "netbird-rt-fwd" + chainNameRoutingNat = "netbird-rt-postrouting" + chainNameRoutingRdr = "netbird-rt-redirect" + chainNameNATOutput = "netbird-nat-output" + chainNameForward = "FORWARD" + chainNameMangleForward = "netbird-mangle-forward" + + // Peer ACL chain names. + chainNameInputRules = "netbird-acl-input-rules" + chainNameInputFilter = "netbird-acl-input-filter" + chainNameForwardFilter = "netbird-acl-forward-filter" + chainNameManglePrerouting = "netbird-mangle-prerouting" + chainNameManglePostrouting = "netbird-mangle-postrouting" + + flushError = "flush: %w" + + firewalldTableName = "firewalld" + + userDataAcceptForwardRuleIif = "frwacceptiif" + userDataAcceptForwardRuleOif = "frwacceptoif" + userDataAcceptInputRule = "inputaccept" + + dnatSuffix firewall.RuleID = "_dnat" + snatSuffix firewall.RuleID = "_snat" + + // ipv4TCPHeaderSize is the minimum IPv4 (20) + TCP (20) header size for MSS calculation. + ipv4TCPHeaderSize = 40 + // ipv6TCPHeaderSize is the minimum IPv6 (40) + TCP (20) header size for MSS calculation. + ipv6TCPHeaderSize = 60 + + // maxPrefixesSet 1638 prefixes start to fail, taking some margin + maxPrefixesSet = 1500 + refreshRulesMapError = "refresh rules map: %w" +) + +var ( + errFilterTableNotFound = fmt.Errorf("'filter' table not found") +) + +type setInput struct { + set firewall.Set + prefixes []netip.Prefix +} + +// family holds the per-address-family nftables state. One instance +// handles route ACLs, peer ACLs, NAT, DNAT, and MSS clamping for a +// single family; the top-level Manager owns one for v4 and another +// for v6. The name predates the peer-ACL absorption; it's effectively +// the per-family backend now. +type family struct { + conn *nftables.Conn + workTable *nftables.Table + filterTable *nftables.Table + chains map[string]*nftables.Chain + + // filters holds peer + route filter rules keyed by content hash. + // AddFilterRule writes here; DeleteFilterRule looks up by id. + filters map[firewall.RuleID]*Rule + + // rules holds NAT, DNAT, and external accept rules (auxiliary + // plumbing that isn't a filter rule). + rules map[firewall.RuleID]*nftables.Rule + + // Peer ACL chain handles. + chainInputRules *nftables.Chain + chainPrerouting *nftables.Chain + routingFwChainName string + + ipsetCounter *refcounter.Counter[string, setInput, *nftables.Set] + + af addrFamily + wgIface iFaceMapper + ipFwdState *ipfwdstate.IPForwardingState + legacyManagement bool + mtu uint16 +} + +func newFamily(workTable *nftables.Table, wgIface iFaceMapper, mtu uint16) *family { + r := &family{ + conn: &nftables.Conn{}, + workTable: workTable, + chains: make(map[string]*nftables.Chain), + filters: make(map[firewall.RuleID]*Rule), + rules: make(map[firewall.RuleID]*nftables.Rule), + routingFwChainName: chainNameRoutingFw, + af: familyForAddr(workTable.Family == nftables.TableFamilyIPv4), + wgIface: wgIface, + ipFwdState: ipfwdstate.NewIPForwardingState(wgIface.Name()), + mtu: mtu, + } + + r.ipsetCounter = refcounter.New( + r.createIpSet, + r.deleteIpSet, + ) + + var err error + r.filterTable, err = r.loadFilterTable() + if err != nil { + log.Debugf("ip filter table not found: %v", err) + } + + return r +} + +func (r *family) init(workTable *nftables.Table) error { + r.workTable = workTable + + if err := r.removeAcceptFilterRules(); err != nil { + log.Errorf("failed to clean up rules from filter table: %s", err) + } + + if err := r.createContainers(); err != nil { + return fmt.Errorf("create containers: %w", err) + } + + if err := r.setupDataPlaneMark(); err != nil { + log.Errorf("failed to set up data plane mark: %v", err) + } + + if err := r.createDefaultChains(); err != nil { + return fmt.Errorf("create default acl chains: %w", err) + } + + return nil +} + +// Reset cleans existing nftables filter table rules from the system +func (r *family) Reset() error { + // clear without deleting the ipsets, the nf table will be deleted by the caller + r.ipsetCounter.Clear() + + var merr *multierror.Error + + if err := r.removeAcceptFilterRules(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove accept filter rules: %w", err)) + } + + if err := firewalld.UntrustInterface(r.wgIface.Name()); err != nil { + merr = multierror.Append(merr, err) + } + + if err := r.removeNatPreroutingRules(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove filter prerouting rules: %w", err)) + } + + return nberrors.FormatErrorOrNil(merr) +} + +func (r *family) loadFilterTable() (*nftables.Table, error) { + tables, err := r.conn.ListTablesOfFamily(r.af.tableFamily) + if err != nil { + return nil, fmt.Errorf("list tables: %w", err) + } + + for _, table := range tables { + if table.Name == "filter" { + return table, nil + } + } + + return nil, errFilterTableNotFound +} + +func hookName(hook *nftables.ChainHook) string { + if hook == nil { + return "unknown" + } + switch *hook { + case *nftables.ChainHookForward: + return chainNameForward + case *nftables.ChainHookInput: + return chainNameInput + default: + return fmt.Sprintf("hook(%d)", *hook) + } +} + +func familyName(family nftables.TableFamily) string { + switch family { + case nftables.TableFamilyIPv4: + return "ip" + case nftables.TableFamilyIPv6: + return "ip6" + case nftables.TableFamilyINet: + return "inet" + default: + return fmt.Sprintf("family(%d)", family) + } +} + +func (r *family) iptablesProto() iptables.Protocol { + if r.af.tableFamily == nftables.TableFamilyIPv6 { + return iptables.ProtocolIPv6 + } + return iptables.ProtocolIPv4 +} + +func (r *family) refreshRulesMap() error { + var merr *multierror.Error + newRules := make(map[firewall.RuleID]*nftables.Rule) + for _, chain := range r.chains { + rules, err := r.conn.GetRules(chain.Table, chain) + if err != nil { + merr = multierror.Append(merr, fmt.Errorf("list rules for chain %s: %w", chain.Name, err)) + // preserve existing entries for this chain since we can't verify their state + for k, v := range r.rules { + if v.Chain != nil && v.Chain.Name == chain.Name { + newRules[k] = v + } + } + continue + } + for _, rule := range rules { + if len(rule.UserData) > 0 { + newRules[firewall.RuleID(rule.UserData)] = rule + } + } + } + r.rules = newRules + return nberrors.FormatErrorOrNil(merr) +} diff --git a/client/firewall/nftables/filter_linux.go b/client/firewall/nftables/filter_linux.go new file mode 100644 index 000000000..ebd238063 --- /dev/null +++ b/client/firewall/nftables/filter_linux.go @@ -0,0 +1,540 @@ +//go:build !android + +package nftables + +import ( + "fmt" + "net" + "net/netip" + "slices" + + "github.com/google/nftables" + "github.com/google/nftables/binaryutil" + "github.com/google/nftables/expr" + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + + nberrors "github.com/netbirdio/netbird/client/errors" + firewall "github.com/netbirdio/netbird/client/firewall/manager" + nbid "github.com/netbirdio/netbird/client/internal/acl/id" +) + +// AddFilterRule installs one nftables packet-filter rule. With +// destination empty the rule goes to the peer ACL input chain plus a +// paired prerouting mangle rule for the redirect mark. With +// destination set (prefix or named set) it goes to the route ACL +// forward chain. Multi-source rules collapse to one nftables rule +// backed by the shared refcounted hash:net set. +func (r *family) AddFilterRule( + id []byte, + sources []netip.Prefix, + destination firewall.Network, + proto firewall.Protocol, + sPort *firewall.Port, + dPort *firewall.Port, + action firewall.Action, +) (firewall.Rule, error) { + isRoute := !destination.IsZero() + + ruleID := nbid.GenerateRuleID(sources, destination, proto, sPort, dPort, action) + if existing, ok := r.filters[ruleID]; ok { + return existing, nil + } + + srcExprs, err := r.applyNetwork(sourceNetwork(sources), sources, true) + if err != nil { + return nil, fmt.Errorf("apply source: %w", err) + } + + var exprs []expr.Any + if isRoute { + exprs, err = r.buildRouteFilterExprs(srcExprs, destination, proto, sPort, dPort) + } else { + exprs, err = r.buildPeerFilterExprs(srcExprs, proto, sPort, dPort) + } + if err != nil { + r.dropNetworkMatch(srcExprs) + return nil, err + } + + mainExprs := slices.Clone(exprs) + verdict := expr.VerdictAccept + if action == firewall.ActionDrop { + verdict = expr.VerdictDrop + } + mainExprs = append(mainExprs, &expr.Verdict{Kind: verdict}) + + chain := r.chainInputRules + if isRoute { + chain = r.chains[chainNameRoutingFw] + } + + userData := []byte(ruleID) + + // Build the paired prerouting mangle rule before flushing so both + // rules commit in one transaction. An anonymous port set binds to + // exactly one rule, so the mangle rule needs its own expression list + // with fresh sets, not a clone of the main rule's. Guard on the + // prerouting chain first: building the expressions queues the port + // set, so skipping the build when there is no chain to bind it to + // keeps an unbound set out of the connection batch. + var mangleRule *nftables.Rule + if !isRoute && r.chainPrerouting != nil { + mangleExprs, err := r.buildPeerFilterExprs(srcExprs, proto, sPort, dPort) + if err != nil { + r.dropNetworkMatch(exprs) + return nil, fmt.Errorf("build mangle rule: %w", err) + } + mangleRule = r.queuePreroutingRule(mangleExprs, userData) + } + + nftRule := &nftables.Rule{ + Table: r.workTable, + Chain: chain, + Exprs: mainExprs, + UserData: userData, + } + if action == firewall.ActionDrop { + nftRule = r.conn.InsertRule(nftRule) + } else { + nftRule = r.conn.AddRule(nftRule) + } + if err := r.conn.Flush(); err != nil { + r.dropNetworkMatch(exprs) + return nil, fmt.Errorf(flushError, err) + } + + rule := &Rule{ + nftRule: nftRule, + mangleRule: mangleRule, + sources: sources, + id: ruleID, + } + r.filters[ruleID] = rule + + log.Debugf("added filter rule: sources=%v, destination=%v, proto=%v, sPort=%v, dPort=%v, action=%v", + sources, destination, proto, sPort, dPort, action) + return rule, nil +} + +// buildPeerFilterExprs assembles the input-chain (peer ACL) match: the +// IP-header protocol byte read via Payload, then source, then ports +// (no counter), matching the historical peer shape so per-rule kernel +// state is identical to pre-unification. +func (r *family) buildPeerFilterExprs( + srcExprs []expr.Any, + proto firewall.Protocol, + sPort, dPort *firewall.Port, +) ([]expr.Any, error) { + var exprs []expr.Any + + if proto != firewall.ProtocolALL { + protoNum, err := r.af.protoNum(proto) + if err != nil { + return nil, fmt.Errorf("convert protocol to number: %w", err) + } + exprs = append(exprs, + &expr.Payload{ + DestRegister: 1, + Base: expr.PayloadBaseNetworkHeader, + Offset: r.af.protoOffset, + Len: 1, + }, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{protoNum}}, + ) + } + exprs = append(exprs, srcExprs...) + + portExprs, err := r.applyPorts(sPort, dPort) + if err != nil { + return nil, err + } + exprs = append(exprs, portExprs...) + return exprs, nil +} + +// buildRouteFilterExprs assembles the forward-chain (route ACL) match: +// source, then destination, then optional proto/ports, then a counter. +func (r *family) buildRouteFilterExprs( + srcExprs []expr.Any, + destination firewall.Network, + proto firewall.Protocol, + sPort, dPort *firewall.Port, +) ([]expr.Any, error) { + exprs := append([]expr.Any{}, srcExprs...) + + destExprs, err := r.applyNetwork(destination, nil, false) + if err != nil { + return nil, fmt.Errorf("apply destination: %w", err) + } + exprs = append(exprs, destExprs...) + + if proto != firewall.ProtocolALL { + protoNum, err := r.af.protoNum(proto) + if err != nil { + r.dropNetworkMatch(destExprs) + return nil, fmt.Errorf("convert protocol to number: %w", err) + } + exprs = append(exprs, + &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{protoNum}}, + ) + + portExprs, err := r.applyPorts(sPort, dPort) + if err != nil { + r.dropNetworkMatch(destExprs) + return nil, err + } + exprs = append(exprs, portExprs...) + } + + exprs = append(exprs, &expr.Counter{}) + return exprs, nil +} + +func (r *family) hasRule(id firewall.RuleID) bool { + _, ok := r.filters[id] + return ok +} + +func (r *family) hasDNATRule(id firewall.RuleID) bool { + _, ok := r.rules[id+dnatSuffix] + return ok +} + +// DeleteFilterRule removes a previously installed filter rule. Source +// set references are recovered from the stored rule's expressions via +// findSets and dropped from the shared refcounter. +func (r *family) DeleteFilterRule(rule firewall.Rule) error { + ruleID := rule.ID() + pr, ok := r.filters[ruleID] + if !ok { + log.Debugf("filter rule %s not found", ruleID) + return nil + } + + // A freshly added rule carries no handle until it is read back from + // the kernel, and Flush only refreshes the peer chains. Pull live + // handles for this rule's chain before deciding it is stale so route + // rules (which Flush never refreshes) can actually be deleted. A + // refresh failure aborts the delete without touching tracking state, + // so the caller can retry while the rule may still exist in the kernel. + if pr.nftRule.Handle == 0 { + if err := r.refreshRuleHandles(pr.nftRule.Chain, false); err != nil { + return fmt.Errorf("refresh handles for chain %s: %w", pr.nftRule.Chain.Name, err) + } + } + // Refresh the mangle handle independently: the main rule's handle can + // be populated while the prerouting refresh during Flush failed, and + // gating the mangle refresh on the main handle would leak the mangle + // rule on delete. + if pr.mangleRule != nil && pr.mangleRule.Handle == 0 { + if err := r.refreshRuleHandles(r.chainPrerouting, true); err != nil { + return fmt.Errorf("refresh mangle handles: %w", err) + } + } + + if pr.nftRule.Handle == 0 { + log.Warnf("filter rule %s has no handle, removing stale entry", ruleID) + // The paired mangle rule can still be in the kernel with a live + // handle. Dropping the tracking entry without removing it would + // leave a prerouting rule that nothing can find again. + if err := r.deleteMangleRule(pr, ruleID); err != nil { + return err + } + r.dropNetworkMatch(pr.nftRule.Exprs) + delete(r.filters, ruleID) + return nil + } + + if err := r.conn.DelRule(pr.nftRule); err != nil { + log.Errorf("queue rule delete: %v", err) + } + r.queueMangleDelete(pr) + if err := r.conn.Flush(); err != nil { + return fmt.Errorf("flush delete %s: %w", ruleID, err) + } + + r.dropNetworkMatch(pr.nftRule.Exprs) + delete(r.filters, ruleID) + return nil +} + +// deleteMangleRule removes the prerouting rule paired with a filter rule on +// its own, for the paths that drop the filter rule's tracking without queueing +// a delete for it. +func (r *family) deleteMangleRule(pr *Rule, ruleID firewall.RuleID) error { + if pr.mangleRule == nil || pr.mangleRule.Handle == 0 { + return nil + } + + r.queueMangleDelete(pr) + if err := r.conn.Flush(); err != nil { + return fmt.Errorf("flush mangle delete %s: %w", ruleID, err) + } + return nil +} + +// queueMangleDelete queues the delete of the rule's prerouting counterpart, if +// it has one. The caller commits it. +func (r *family) queueMangleDelete(pr *Rule) { + if pr.mangleRule == nil { + return + } + if err := r.conn.DelRule(pr.mangleRule); err != nil { + log.Errorf("queue mangle rule delete: %v", err) + } +} + +func (r *family) decrementSetCounter(rule *nftables.Rule) error { + if r.ipsetCounter == nil { + return nil + } + sets := findSets(rule) + + var merr *multierror.Error + for _, setName := range sets { + if _, err := r.ipsetCounter.Decrement(setName); err != nil { + merr = multierror.Append(merr, fmt.Errorf("decrement set counter: %w", err)) + } + } + + return nberrors.FormatErrorOrNil(merr) +} + +// dropNetworkMatch undoes whatever the source/destination match +// reserved. Safe to call when the spec is empty or holds only inline +// matchers. +func (r *family) dropNetworkMatch(exprs []expr.Any) { + if r.ipsetCounter == nil { + return + } + for _, e := range exprs { + lookup, ok := e.(*expr.Lookup) + if !ok { + continue + } + if _, err := r.ipsetCounter.Decrement(lookup.SetName); err != nil { + log.Errorf("rollback ipset decrement %s: %v", lookup.SetName, err) + } + } +} + +func (r *family) applyNetwork( + network firewall.Network, + setPrefixes []netip.Prefix, + isSource bool, +) ([]expr.Any, error) { + if network.IsSet() { + exprs, err := r.getIpSet(network.Set, setPrefixes, isSource) + if err != nil { + side := "destination" + if isSource { + side = "source" + } + return nil, fmt.Errorf("%s set: %w", side, err) + } + return exprs, nil + } + + if network.IsPrefix() { + return prefixMatchExprs(r.af, network.Prefix, isSource), nil + } + + return nil, nil +} + +// applyPort builds the transport-header port match. A single value +// compares directly, a range uses a range expression, and multiple +// values go through an anonymous constant set: consecutive cmp +// expressions AND together, so chained equality comparisons could +// never match more than one port. The set is queued on the +// connection and committed by the caller's flush together with the +// rule that binds it. +func (r *family) applyPort(port *firewall.Port, isSource bool) ([]expr.Any, error) { + if port == nil || len(port.Values) == 0 { + return nil, nil + } + + // dst port + offset := uint32(2) + if isSource { + // src port + offset = 0 + } + + exprs := []expr.Any{ + &expr.Payload{ + DestRegister: 1, + Base: expr.PayloadBaseTransportHeader, + Offset: offset, + Len: 2, + }, + } + + switch { + case port.IsRange && len(port.Values) == 2: + exprs = append(exprs, &expr.Range{ + Op: expr.CmpOpEq, + Register: 1, + FromData: binaryutil.BigEndian.PutUint16(port.Values[0]), + ToData: binaryutil.BigEndian.PutUint16(port.Values[1]), + }) + case len(port.Values) == 1: + exprs = append(exprs, &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: binaryutil.BigEndian.PutUint16(port.Values[0]), + }) + default: + lookup, err := r.anonymousPortSet(port.Values) + if err != nil { + return nil, err + } + exprs = append(exprs, lookup) + } + + return exprs, nil +} + +// anonymousPortSet queues an anonymous constant set holding the given +// ports on the connection and returns a lookup against it. The set is +// committed by the caller's flush together with the rule that binds it. +func (r *family) anonymousPortSet(values []uint16) (*expr.Lookup, error) { + set := &nftables.Set{ + Anonymous: true, + Constant: true, + Table: r.workTable, + KeyType: nftables.TypeInetService, + } + elements := make([]nftables.SetElement, 0, len(values)) + for _, p := range values { + elements = append(elements, nftables.SetElement{Key: binaryutil.BigEndian.PutUint16(p)}) + } + if err := r.conn.AddSet(set, elements); err != nil { + return nil, fmt.Errorf("add anonymous port set: %w", err) + } + return &expr.Lookup{ + SourceRegister: 1, + SetID: set.ID, + SetName: set.Name, + }, nil +} + +// applyPorts builds the source then destination port matches. +func (r *family) applyPorts(sPort, dPort *firewall.Port) ([]expr.Any, error) { + sPortExprs, err := r.applyPort(sPort, true) + if err != nil { + return nil, fmt.Errorf("apply source port: %w", err) + } + + dPortExprs, err := r.applyPort(dPort, false) + if err != nil { + return nil, fmt.Errorf("apply destination port: %w", err) + } + + return append(sPortExprs, dPortExprs...), nil +} + +// prefixMatchExprs is the family-aware match sequence for a CIDR +// prefix. /0 returns nil; a host prefix (full bit length for the +// family) skips the bitwise step since the mask is all-ones. Shared +// between family and aclManager so both treat single prefixes +// identically. +func prefixMatchExprs(af addrFamily, prefix netip.Prefix, isSource bool) []expr.Any { + offset := af.dstAddrOffset + if isSource { + offset = af.srcAddrOffset + } + + ones := prefix.Bits() + if ones == 0 { + return nil + } + + payload := &expr.Payload{ + DestRegister: 1, + Base: expr.PayloadBaseNetworkHeader, + Offset: offset, + Len: af.addrLen, + } + cmp := &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: prefix.Masked().Addr().AsSlice(), + } + + if ones == af.totalBits { + return []expr.Any{payload, cmp} + } + + mask := net.CIDRMask(ones, af.totalBits) + xor := make([]byte, af.addrLen) + return []expr.Any{ + payload, + &expr.Bitwise{ + DestRegister: 1, + SourceRegister: 1, + Len: af.addrLen, + Mask: mask, + Xor: xor, + }, + cmp, + } +} + +func getCtNewExprs() []expr.Any { + return []expr.Any{ + &expr.Ct{ + Key: expr.CtKeySTATE, + Register: 1, + }, + &expr.Bitwise{ + SourceRegister: 1, + DestRegister: 1, + Len: 4, + Mask: binaryutil.NativeEndian.PutUint32(expr.CtStateBitNEW), + Xor: binaryutil.NativeEndian.PutUint32(0), + }, + &expr.Cmp{ + Op: expr.CmpOpNeq, + Register: 1, + Data: []byte{0, 0, 0, 0}, + }, + } +} + +// sourceNetwork classifies a source-prefix list into the firewall.Network +// shape the rest of the spec-builder consumes: empty for match-any, a +// single prefix inline, or an ipset for multiple sources. +func sourceNetwork(sources []netip.Prefix) firewall.Network { + switch { + case len(sources) == 0: + return firewall.Network{} + case len(sources) == 1 && sources[0].Bits() == 0: + return firewall.Network{} + case len(sources) == 1: + return firewall.Network{Prefix: sources[0]} + default: + return firewall.Network{Set: firewall.NewPrefixSet(sources)} + } +} + +func ifname(n string) []byte { + b := make([]byte, 16) + copy(b, n+"\x00") + return b +} + +// findSets scans an nftables rule's expressions for expr.Lookup and +// returns the named sets in occurrence order. Used at delete time to +// drop ipsetCounter references; peer and route ACLs go through it. +func findSets(rule *nftables.Rule) []string { + var sets []string + for _, e := range rule.Exprs { + if lookup, ok := e.(*expr.Lookup); ok { + sets = append(sets, lookup.SetName) + } + } + return sets +} diff --git a/client/firewall/nftables/interface_allower_integration_linux_test.go b/client/firewall/nftables/interface_allower_integration_linux_test.go new file mode 100644 index 000000000..4d4bc6187 --- /dev/null +++ b/client/firewall/nftables/interface_allower_integration_linux_test.go @@ -0,0 +1,90 @@ +//go:build privileged + +package nftables + +import ( + "bytes" + "os" + "testing" + + "github.com/google/nftables" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/iface" +) + +// TestInterfaceAllowerInputOnly verifies the userspace-mode allower opens the +// interface on the INPUT hook of foreign chains only (not FORWARD, since the +// userspace router never forwards in the kernel), creates no netbird work +// table, and removes its rules on Close. +func TestInterfaceAllowerInputOnly(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("root required") + } + + require.False(t, ipTableExists(t, getTableName()), "precondition: no stale netbird table") + + conn := &nftables.Conn{} + extTable := conn.AddTable(&nftables.Table{Name: "nbtest_extchains", Family: nftables.TableFamilyINet}) + inputChain := conn.AddChain(&nftables.Chain{ + Name: "ext_input", Table: extTable, + Hooknum: nftables.ChainHookInput, Priority: nftables.ChainPriorityFilter, Type: nftables.ChainTypeFilter, + }) + forwardChain := conn.AddChain(&nftables.Chain{ + Name: "ext_forward", Table: extTable, + Hooknum: nftables.ChainHookForward, Priority: nftables.ChainPriorityFilter, Type: nftables.ChainTypeFilter, + }) + require.NoError(t, conn.Flush(), "create external table and chains") + t.Cleanup(func() { + c := &nftables.Conn{} + c.DelTable(extTable) + _ = c.Flush() + }) + + allower, err := NewInterfaceAllower(ifaceMock, iface.DefaultMTU) + require.NoError(t, err, "create allower") + require.NoError(t, allower.Apply(), "apply") + + require.True(t, chainHasUserData(t, extTable, inputChain, userDataAcceptInputRule), + "external INPUT chain should get the accept rule") + require.Len(t, listRules(t, extTable, forwardChain), 0, + "external FORWARD chain must not be opened in userspace mode") + require.False(t, ipTableExists(t, getTableName()), + "allower must not create a netbird work table") + + require.NoError(t, allower.Close(), "close") + require.False(t, chainHasUserData(t, extTable, inputChain, userDataAcceptInputRule), + "accept rule should be removed on close") +} + +func listRules(t *testing.T, table *nftables.Table, chain *nftables.Chain) []*nftables.Rule { + t.Helper() + c := &nftables.Conn{} + rules, err := c.GetRules(table, chain) + require.NoError(t, err) + return rules +} + +func chainHasUserData(t *testing.T, table *nftables.Table, chain *nftables.Chain, ud string) bool { + for _, r := range listRules(t, table, chain) { + if bytes.Equal(r.UserData, []byte(ud)) { + return true + } + } + return false +} + +func ipTableExists(t *testing.T, name string) bool { + t.Helper() + c := &nftables.Conn{} + for _, fam := range []nftables.TableFamily{nftables.TableFamilyIPv4, nftables.TableFamilyIPv6} { + tbls, err := c.ListTablesOfFamily(fam) + require.NoError(t, err) + for _, tb := range tbls { + if tb.Name == name { + return true + } + } + } + return false +} diff --git a/client/firewall/nftables/interface_allower_linux.go b/client/firewall/nftables/interface_allower_linux.go new file mode 100644 index 000000000..e7232e2bf --- /dev/null +++ b/client/firewall/nftables/interface_allower_linux.go @@ -0,0 +1,107 @@ +package nftables + +import ( + "fmt" + + "github.com/google/nftables" + "github.com/hashicorp/go-multierror" + + nberrors "github.com/netbirdio/netbird/client/errors" +) + +// InterfaceAllower opens the NetBird interface in the kernel's filter table and +// external chains and keeps them reconciled via a netlink monitor, so the host +// firewall doesn't drop traffic the NetBird firewall handles. It is used by the +// userspace firewall, where routing happens in the forwarder, so only INPUT is +// opened (the userspace router never forwards in the kernel). +// +// It owns its own families/connection and never creates a netbird work table. +// firewalld trust is handled by the caller, not here. Its operations are serial +// (Apply before the monitor starts; reconciles run on the single monitor +// goroutine; Close stops the monitor before removing), so it needs no locking. +// +// TODO: this opens nftables and the iptables-nft filter table (detected via +// nft), but not a legacy-iptables ruleset running in parallel with nftables. +// Such a host would keep its legacy filter chains closed for the interface. +type InterfaceAllower struct { + family4 *family + family6 *family + extMonitor *externalChainMonitor +} + +// NewInterfaceAllower builds an allower for the given interface. It returns an +// error when nftables is unavailable (e.g. an iptables-legacy host), so the +// caller can fall back to firewalld trust. +func NewInterfaceAllower(wgIface iFaceMapper, mtu uint16) (*InterfaceAllower, error) { + tableName := getTableName() + + family4 := newFamily(&nftables.Table{Name: tableName, Family: nftables.TableFamilyIPv4}, wgIface, mtu) + + // Probe nftables availability before committing to this backend. + if _, err := family4.conn.ListChainsOfTableFamily(nftables.TableFamilyINet); err != nil { + return nil, fmt.Errorf("nftables not available: %w", err) + } + + a := &InterfaceAllower{family4: family4} + + if wgIface.Address().HasIPv6() { + a.family6 = newFamily(&nftables.Table{Name: tableName, Family: nftables.TableFamilyIPv6}, wgIface, mtu) + } + + a.extMonitor = newExternalChainMonitor(a) + return a, nil +} + +// Apply opens the interface (INPUT only) in the foreign filter chains and starts +// reconciling them on nftables changes. +func (a *InterfaceAllower) Apply() error { + var merr *multierror.Error + for _, f := range a.families() { + // Remove any stale accepts first so a prior unclean exit (e.g. SIGKILL, + // where Close never ran) is recovered deterministically rather than + // accumulating duplicate rules on the iptables filter table. + if err := f.removeAcceptFilterRules(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("clean stale accept rules: %w", err)) + } + if err := f.openInterface(false); err != nil { + merr = multierror.Append(merr, err) + } + } + + a.extMonitor.start() + return nberrors.FormatErrorOrNil(merr) +} + +// families returns the configured address families (v4, and v6 when present). +func (a *InterfaceAllower) families() []*family { + families := []*family{a.family4} + if a.family6 != nil { + families = append(families, a.family6) + } + return families +} + +// reconcileExternalChains re-applies the INPUT accepts to external chains. It +// implements externalChainReconciler for the monitor. +func (a *InterfaceAllower) reconcileExternalChains() error { + var merr *multierror.Error + for _, f := range a.families() { + if err := f.acceptExternalChainsRules(false); err != nil { + merr = multierror.Append(merr, err) + } + } + return nberrors.FormatErrorOrNil(merr) +} + +// Close stops the monitor and removes the accept rules. +func (a *InterfaceAllower) Close() error { + a.extMonitor.stop() + + var merr *multierror.Error + for _, f := range a.families() { + if err := f.removeAcceptFilterRules(); err != nil { + merr = multierror.Append(merr, err) + } + } + return nberrors.FormatErrorOrNil(merr) +} diff --git a/client/firewall/nftables/ipset_linux.go b/client/firewall/nftables/ipset_linux.go new file mode 100644 index 000000000..34783bbeb --- /dev/null +++ b/client/firewall/nftables/ipset_linux.go @@ -0,0 +1,210 @@ +//go:build !android + +package nftables + +import ( + "encoding/binary" + "fmt" + "net/netip" + + "github.com/google/nftables" + "github.com/google/nftables/expr" + log "github.com/sirupsen/logrus" + + firewall "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/internal/routemanager/refcounter" +) + +func (r *family) getIpSet(set firewall.Set, prefixes []netip.Prefix, isSource bool) ([]expr.Any, error) { + ref, err := r.ipsetCounter.Increment(set.HashedName(), setInput{ + set: set, + prefixes: prefixes, + }) + if err != nil { + return nil, fmt.Errorf("create or get ipset: %w", err) + } + + return r.getIpSetExprs(ref, isSource) +} + +func (r *family) createIpSet(setName string, input setInput) (*nftables.Set, error) { + // overlapping prefixes will result in an error, so we need to merge them + prefixes := firewall.MergeIPRanges(input.prefixes) + + nfset := &nftables.Set{ + Name: setName, + Comment: input.set.Comment(), + Table: r.workTable, + // required for prefixes + Interval: true, + KeyType: r.af.setKeyType, + } + + elements := r.convertPrefixesToSet(prefixes) + nElements := len(elements) + + maxElements := maxPrefixesSet * 2 + initialElements := elements[:min(maxElements, nElements)] + + if err := r.conn.AddSet(nfset, initialElements); err != nil { + return nil, fmt.Errorf("error adding set %s: %w", setName, err) + } + if err := r.conn.Flush(); err != nil { + return nil, fmt.Errorf("flush error: %w", err) + } + log.Debugf("Created new ipset: %s with %d initial prefixes (total prefixes %d)", setName, len(initialElements)/2, len(prefixes)) + + // The set is committed now. If a later batch fails, destroy it: the + // refcounter records nothing on a create-callback error, so it would + // otherwise leak, and a partial source set fails-open for deny rules. + if err := r.addRemainingElements(nfset, elements, maxElements); err != nil { + if derr := r.deleteIpSet(setName, nfset); derr != nil { + log.Warnf("rollback ipset %s after add failure: %v", setName, derr) + } + return nil, err + } + + log.Infof("Created new ipset: %s with %d prefixes", setName, len(prefixes)) + return nfset, nil +} + +// addRemainingElements adds element batches beyond the initial one in +// maxElements-sized chunks, flushing each. Called after the set has been +// created with its first batch. +func (r *family) addRemainingElements(nfset *nftables.Set, elements []nftables.SetElement, maxElements int) error { + nElements := len(elements) + for subStart := maxElements; subStart < nElements; subStart += maxElements { + subEnd := min(subStart+maxElements, nElements) + subElement := elements[subStart:subEnd] + nSubPrefixes := len(subElement) / 2 + log.Tracef("Adding new prefixes (%d) in ipset: %s", nSubPrefixes, nfset.Name) + if err := r.conn.SetAddElements(nfset, subElement); err != nil { + return fmt.Errorf("error adding prefixes (%d) to set %s: %w", nSubPrefixes, nfset.Name, err) + } + if err := r.conn.Flush(); err != nil { + return fmt.Errorf("flush error: %w", err) + } + log.Debugf("Added new prefixes (%d) in ipset: %s", nSubPrefixes, nfset.Name) + } + return nil +} + +func (r *family) convertPrefixesToSet(prefixes []netip.Prefix) []nftables.SetElement { + var elements []nftables.SetElement + for _, prefix := range prefixes { + // nftables needs half-open intervals [firstIP, lastIP) for prefixes + // e.g. 10.0.0.0/24 becomes [10.0.0.0, 10.0.1.0), 10.1.1.1/32 becomes [10.1.1.1, 10.1.1.2) etc + firstIP := prefix.Addr() + + // For a /0 the last address is the broadcast and its Next() overflows + // to an invalid Addr with an empty key, so wrap to the zero address, + // which nftables reads as the open end of a full-range interval. + var lastKey []byte + if prefix.Bits() == 0 { + lastKey = make([]byte, r.af.addrLen) + } else { + lastKey = calculateLastIP(prefix).Next().AsSlice() + } + + // the nft tool also adds a zero-address IntervalEnd element, see https://github.com/google/nftables/issues/247 + // nftables.SetElement{Key: make([]byte, r.af.addrLen), IntervalEnd: true}, + elements = append(elements, + nftables.SetElement{Key: firstIP.AsSlice()}, + nftables.SetElement{Key: lastKey, IntervalEnd: true}, + ) + } + return elements +} + +// calculateLastIP determines the last IP in a given prefix. +func calculateLastIP(prefix netip.Prefix) netip.Addr { + masked := prefix.Masked() + if masked.Addr().Is4() { + hostMask := ^uint32(0) >> masked.Bits() + lastIP := uint32FromNetipAddr(masked.Addr()) | hostMask + return netip.AddrFrom4(uint32ToBytes(lastIP)) + } + + // IPv6: set host bits to all 1s + b := masked.Addr().As16() + bits := masked.Bits() + for i := bits; i < 128; i++ { + b[i/8] |= 1 << (7 - i%8) + } + return netip.AddrFrom16(b) +} + +// Utility function to convert netip.Addr to uint32. +func uint32FromNetipAddr(addr netip.Addr) uint32 { + b := addr.As4() + return binary.BigEndian.Uint32(b[:]) +} + +// Utility function to convert uint32 to a netip-compatible byte slice. +func uint32ToBytes(ip uint32) [4]byte { + var b [4]byte + binary.BigEndian.PutUint32(b[:], ip) + return b +} + +func (r *family) deleteIpSet(setName string, nfset *nftables.Set) error { + r.conn.DelSet(nfset) + if err := r.conn.Flush(); err != nil { + return fmt.Errorf(flushError, err) + } + + log.Debugf("Deleted unused ipset %s", setName) + return nil +} + +func (r *family) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error { + nfset, err := r.conn.GetSetByName(r.workTable, set.HashedName()) + if err != nil { + return fmt.Errorf("get set %s: %w", set.HashedName(), err) + } + + // Overlapping prefixes (e.g. duplicate resolved addresses) make the + // interval set reject the batch, so merge them as createIpSet does. + prefixes = firewall.MergeIPRanges(prefixes) + elements := r.convertPrefixesToSet(prefixes) + + // Add in batches sized like createIpSet so a large update does not + // exceed the netlink message size limit. + maxElements := maxPrefixesSet * 2 + for start := 0; start < len(elements); start += maxElements { + end := min(start+maxElements, len(elements)) + if err := r.conn.SetAddElements(nfset, elements[start:end]); err != nil { + return fmt.Errorf("add elements to set %s: %w", set.HashedName(), err) + } + if err := r.conn.Flush(); err != nil { + return fmt.Errorf(flushError, err) + } + } + + log.Debugf("updated set %s with %d prefixes", set.HashedName(), len(prefixes)) + + return nil +} + +func (r *family) getIpSetExprs(ref refcounter.Ref[*nftables.Set], isSource bool) ([]expr.Any, error) { + // dst offset by default + offset := r.af.dstAddrOffset + if isSource { + // src offset + offset = r.af.srcAddrOffset + } + + return []expr.Any{ + &expr.Payload{ + DestRegister: 1, + Base: expr.PayloadBaseNetworkHeader, + Offset: offset, + Len: r.af.addrLen, + }, + &expr.Lookup{ + SourceRegister: 1, + SetName: ref.Out.Name, + SetID: ref.Out.ID, + }, + }, nil +} diff --git a/client/firewall/nftables/ipset_linux_test.go b/client/firewall/nftables/ipset_linux_test.go new file mode 100644 index 000000000..7ab2f6c3f --- /dev/null +++ b/client/firewall/nftables/ipset_linux_test.go @@ -0,0 +1,36 @@ +package nftables + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestConvertPrefixesToSetWildcard verifies that a /0 prefix produces a +// usable interval. The last address of a /0 is the broadcast, whose Next() +// overflows to an invalid Addr with an empty key; the IntervalEnd must wrap +// to the zero address instead so nftables sees a full-range interval. +func TestConvertPrefixesToSetWildcard(t *testing.T) { + tests := []struct { + name string + af addrFamily + prefix string + }{ + {"IPv4 /0", afIPv4, "0.0.0.0/0"}, + {"IPv6 /0", afIPv6, "::/0"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &family{af: tt.af} + elements := r.convertPrefixesToSet([]netip.Prefix{netip.MustParsePrefix(tt.prefix)}) + + require.Len(t, elements, 2, "expected start and interval-end element") + assert.False(t, elements[0].IntervalEnd, "first element is the interval start") + assert.True(t, elements[1].IntervalEnd, "second element is the interval end") + assert.Len(t, elements[1].Key, int(tt.af.addrLen), "interval-end key must be a zero address, not empty") + }) + } +} diff --git a/client/firewall/nftables/ipsetstore_linux.go b/client/firewall/nftables/ipsetstore_linux.go deleted file mode 100644 index a6c2e9496..000000000 --- a/client/firewall/nftables/ipsetstore_linux.go +++ /dev/null @@ -1,85 +0,0 @@ -package nftables - -import ( - "net" -) - -type ipsetStore struct { - ipsetReference map[string]int - ipsets map[string]map[string]struct{} // ipsetName -> list of ips -} - -func newIpsetStore() *ipsetStore { - return &ipsetStore{ - ipsetReference: make(map[string]int), - ipsets: make(map[string]map[string]struct{}), - } -} - -func (s *ipsetStore) ips(ipsetName string) (map[string]struct{}, bool) { - r, ok := s.ipsets[ipsetName] - return r, ok -} - -func (s *ipsetStore) newIpset(ipsetName string) map[string]struct{} { - s.ipsetReference[ipsetName] = 0 - ipList := make(map[string]struct{}) - s.ipsets[ipsetName] = ipList - return ipList -} - -func (s *ipsetStore) deleteIpset(ipsetName string) { - delete(s.ipsetReference, ipsetName) - delete(s.ipsets, ipsetName) -} - -func (s *ipsetStore) DeleteIpFromSet(ipsetName string, ip net.IP) { - ipList, ok := s.ipsets[ipsetName] - if !ok { - return - } - delete(ipList, ip.String()) -} - -func (s *ipsetStore) AddIpToSet(ipsetName string, ip net.IP) { - ipList, ok := s.ipsets[ipsetName] - if !ok { - return - } - ipList[ip.String()] = struct{}{} -} - -func (s *ipsetStore) IsIpInSet(ipsetName string, ip net.IP) bool { - ipList, ok := s.ipsets[ipsetName] - if !ok { - return false - } - _, ok = ipList[ip.String()] - return ok -} - -func (s *ipsetStore) AddReferenceToIpset(ipsetName string) { - s.ipsetReference[ipsetName]++ -} - -func (s *ipsetStore) DeleteReferenceFromIpSet(ipsetName string) { - r, ok := s.ipsetReference[ipsetName] - if !ok { - return - } - if r == 0 { - return - } - s.ipsetReference[ipsetName]-- -} - -func (s *ipsetStore) HasReferenceToSet(ipsetName string) bool { - if _, ok := s.ipsetReference[ipsetName]; !ok { - return false - } - if s.ipsetReference[ipsetName] == 0 { - return false - } - - return true -} diff --git a/client/firewall/nftables/legacy_rule_linux_test.go b/client/firewall/nftables/legacy_rule_linux_test.go new file mode 100644 index 000000000..dc2f1c7a0 --- /dev/null +++ b/client/firewall/nftables/legacy_rule_linux_test.go @@ -0,0 +1,60 @@ +package nftables + +import ( + "testing" + + "github.com/google/nftables/expr" + "github.com/stretchr/testify/require" +) + +func TestBuildLegacyRouteRuleExpressions(t *testing.T) { + sourcePayload := &expr.Payload{} + sourceCmp := &expr.Cmp{} + destinationPayload := &expr.Payload{} + destinationCmp := &expr.Cmp{} + nilSourceDestination := &expr.Payload{} + nilDestinationSource := &expr.Cmp{} + + tests := []struct { + name string + source []expr.Any + destination []expr.Any + matches []expr.Any + }{ + { + name: "both non-empty", + source: []expr.Any{sourcePayload, sourceCmp}, + destination: []expr.Any{destinationPayload, destinationCmp}, + matches: []expr.Any{sourcePayload, sourceCmp, destinationPayload, destinationCmp}, + }, + { + name: "nil source", + destination: []expr.Any{nilSourceDestination}, + matches: []expr.Any{nilSourceDestination}, + }, + { + name: "nil destination", + source: []expr.Any{nilDestinationSource}, + matches: []expr.Any{nilDestinationSource}, + }, + { + name: "both nil", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildLegacyRouteRuleExpressions(tt.source, tt.destination) + + require.Len(t, got, len(tt.matches)+2) + for i, match := range tt.matches { + require.Same(t, match, got[i]) + } + + require.IsType(t, &expr.Counter{}, got[len(tt.matches)]) + verdict, ok := got[len(tt.matches)+1].(*expr.Verdict) + require.True(t, ok) + require.Equal(t, expr.VerdictAccept, verdict.Kind) + }) + } +} diff --git a/client/firewall/nftables/manager_linux.go b/client/firewall/nftables/manager_linux.go index fdc7c2f3c..dbd5e4fa2 100644 --- a/client/firewall/nftables/manager_linux.go +++ b/client/firewall/nftables/manager_linux.go @@ -3,7 +3,6 @@ package nftables import ( "context" "fmt" - "net" "net/netip" "os" "sync" @@ -16,7 +15,6 @@ import ( "golang.org/x/sys/unix" nberrors "github.com/netbirdio/netbird/client/errors" - "github.com/netbirdio/netbird/client/firewall/firewalld" firewall "github.com/netbirdio/netbird/client/firewall/manager" "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/internal/statemanager" @@ -45,18 +43,17 @@ type iFaceMapper interface { Address() wgaddr.Address } -// Manager of iptables firewall +// Manager of nftables firewall. Per-family state (peer ACLs, route +// ACLs, NAT, DNAT, MSS clamping) lives on family; Manager dispatches +// by family and provides the public firewall.Manager surface. type Manager struct { mutex sync.Mutex rConn *nftables.Conn wgIface iFaceMapper - router *router - aclManager *AclManager - - // IPv6 counterparts, nil when no v6 overlay - router6 *router - aclManager6 *AclManager + family4 *family + // IPv6 counterpart, nil when no v6 overlay. + family6 *family notrackOutputChain *nftables.Chain notrackPreroutingChain *nftables.Chain @@ -74,21 +71,10 @@ func Create(wgIface iFaceMapper, mtu uint16) (*Manager, error) { tableName := getTableName() workTable := &nftables.Table{Name: tableName, Family: nftables.TableFamilyIPv4} - var err error - m.router, err = newRouter(workTable, wgIface, mtu) - if err != nil { - return nil, fmt.Errorf("create router: %w", err) - } - - m.aclManager, err = newAclManager(workTable, wgIface, chainNameRoutingFw) - if err != nil { - return nil, fmt.Errorf("create acl manager: %w", err) - } + m.family4 = newFamily(workTable, wgIface, mtu) if wgIface.Address().HasIPv6() { - if err := m.createIPv6Components(tableName, wgIface, mtu); err != nil { - return nil, fmt.Errorf("create IPv6 firewall: %w", err) - } + m.createIPv6Components(tableName, wgIface, mtu) } m.extMonitor = newExternalChainMonitor(m) @@ -96,30 +82,19 @@ func Create(wgIface iFaceMapper, mtu uint16) (*Manager, error) { return m, nil } -func (m *Manager) createIPv6Components(tableName string, wgIface iFaceMapper, mtu uint16) error { +func (m *Manager) createIPv6Components(tableName string, wgIface iFaceMapper, mtu uint16) { workTable6 := &nftables.Table{Name: tableName, Family: nftables.TableFamilyIPv6} - var err error - m.router6, err = newRouter(workTable6, wgIface, mtu) - if err != nil { - return fmt.Errorf("create v6 router: %w", err) - } + m.family6 = newFamily(workTable6, wgIface, mtu) - // Share the same IP forwarding state with the v4 router, since - // EnableIPForwarding controls both v4 and v6 sysctls. - m.router6.ipFwdState = m.router.ipFwdState - - m.aclManager6, err = newAclManager(workTable6, wgIface, chainNameRoutingFw) - if err != nil { - return fmt.Errorf("create v6 acl manager: %w", err) - } - - return nil + // Share the per-family forwarding refcounter with the v4 family so a v4 + // rule and a v6 rule against the same state machine cooperate cleanly. + m.family6.ipFwdState = m.family4.ipFwdState } // hasIPv6 reports whether the manager has IPv6 components initialized. func (m *Manager) hasIPv6() bool { - return m.router6 != nil + return m.family6 != nil } func (m *Manager) initIPv6() error { @@ -128,12 +103,8 @@ func (m *Manager) initIPv6() error { return fmt.Errorf("create v6 work table: %w", err) } - if err := m.router6.init(workTable6); err != nil { - return fmt.Errorf("v6 router init: %w", err) - } - - if err := m.aclManager6.init(workTable6); err != nil { - return fmt.Errorf("v6 acl manager init: %w", err) + if err := m.family6.init(workTable6); err != nil { + return fmt.Errorf("v6 family init: %w", err) } return nil @@ -156,19 +127,20 @@ func (m *Manager) Init(stateManager *statemanager.Manager) error { // reconcileExternalChains re-applies passthrough accept rules to external // filter chains for both IPv4 and IPv6 routers. Called by the monitor when -// tables or chains appear (e.g. after firewalld reloads). +// tables or chains appear (e.g. after firewalld reloads). Kernel routing opens +// both INPUT and FORWARD. func (m *Manager) reconcileExternalChains() error { m.mutex.Lock() defer m.mutex.Unlock() var merr *multierror.Error - if m.router != nil { - if err := m.router.acceptExternalChainsRules(); err != nil { + if m.family4 != nil { + if err := m.family4.acceptExternalChainsRules(true); err != nil { merr = multierror.Append(merr, fmt.Errorf("v4: %w", err)) } } if m.hasIPv6() { - if err := m.router6.acceptExternalChainsRules(); err != nil { + if err := m.family6.acceptExternalChainsRules(true); err != nil { merr = multierror.Append(merr, fmt.Errorf("v6: %w", err)) } } @@ -187,12 +159,8 @@ func (m *Manager) initFirewall() (err error) { } }() - if err := m.router.init(workTable); err != nil { - return fmt.Errorf("router init: %w", err) - } - - if err := m.aclManager.init(workTable); err != nil { - return fmt.Errorf("acl manager init: %w", err) + if err := m.family4.init(workTable); err != nil { + return fmt.Errorf("family init: %w", err) } if m.hasIPv6() { @@ -220,7 +188,7 @@ func (m *Manager) persistState(stateManager *statemanager.Manager) { InterfaceState: &InterfaceState{ NameStr: m.wgIface.Name(), WGAddress: m.wgIface.Address(), - MTU: m.router.mtu, + MTU: m.family4.mtu, }, }); err != nil { log.Errorf("failed to update state: %v", err) @@ -235,12 +203,12 @@ func (m *Manager) persistState(stateManager *statemanager.Manager) { // rollbackInit performs best-effort cleanup of already-initialized state when Init fails partway through. func (m *Manager) rollbackInit() { - if err := m.router.Reset(); err != nil { - log.Warnf("rollback router: %v", err) + if err := m.family4.Reset(); err != nil { + log.Warnf("rollback family: %v", err) } if m.hasIPv6() { - if err := m.router6.Reset(); err != nil { - log.Warnf("rollback v6 router: %v", err) + if err := m.family6.Reset(); err != nil { + log.Warnf("rollback v6 family: %v", err) } } if err := m.cleanupNetbirdTables(); err != nil { @@ -251,118 +219,82 @@ func (m *Manager) rollbackInit() { } } -// AddPeerFiltering rule to the firewall +// AddFilterRule installs a packet-filtering rule. // -// If comment argument is empty firewall manager should set -// rule ID as comment for the rule -func (m *Manager) AddPeerFiltering( - id []byte, - ip net.IP, - proto firewall.Protocol, - sPort *firewall.Port, - dPort *firewall.Port, - action firewall.Action, - ipsetName string, -) ([]firewall.Rule, error) { - m.mutex.Lock() - defer m.mutex.Unlock() - - if ip.To4() != nil { - return m.aclManager.AddPeerFiltering(id, ip, proto, sPort, dPort, action, ipsetName) - } - - if !m.hasIPv6() { - return nil, fmt.Errorf("add peer filtering for %s: %w", ip, firewall.ErrIPv6NotInitialized) - } - return m.aclManager6.AddPeerFiltering(id, ip, proto, sPort, dPort, action, ipsetName) -} - -func (m *Manager) AddRouteFiltering( +// Destination semantics: zero Network → input chain (peer ACL); +// set Network → forward chain (route ACL). +// +// Sources are a single address family; the rule is dispatched to the +// matching per-family backend. +func (m *Manager) AddFilterRule( id []byte, sources []netip.Prefix, destination firewall.Network, proto firewall.Protocol, - sPort, dPort *firewall.Port, + sPort *firewall.Port, + dPort *firewall.Port, action firewall.Action, ) (firewall.Rule, error) { + if len(sources) == 0 { + return nil, firewall.ErrNoSources + } + m.mutex.Lock() defer m.mutex.Unlock() - if isIPv6RouteRule(sources, destination) { + fam := m.family4 + if isIPv6Rule(sources, destination) { if !m.hasIPv6() { - return nil, fmt.Errorf("add route filtering: %w", firewall.ErrIPv6NotInitialized) + return nil, fmt.Errorf("add filtering: %w", firewall.ErrIPv6NotInitialized) } - return m.router6.AddRouteFiltering(id, sources, destination, proto, sPort, dPort, action) + fam = m.family6 } - - return m.router.AddRouteFiltering(id, sources, destination, proto, sPort, dPort, action) + return fam.AddFilterRule(id, sources, destination, proto, sPort, dPort, action) } -// DeletePeerRule from the firewall by rule definition -func (m *Manager) DeletePeerRule(rule firewall.Rule) error { +// DeleteFilterRule removes a filtering rule. The owning family is found +// by id in the in-memory filter maps, which are the only tracking for +// filter rules. family.DeleteFilterRule is idempotent when the id is +// absent. +func (m *Manager) DeleteFilterRule(rule firewall.Rule) error { m.mutex.Lock() defer m.mutex.Unlock() - if m.hasIPv6() && isIPv6Rule(rule) { - return m.aclManager6.DeletePeerRule(rule) - } - return m.aclManager.DeletePeerRule(rule) -} - -func isIPv6Rule(rule firewall.Rule) bool { - r, ok := rule.(*Rule) - return ok && r.nftRule != nil && r.nftRule.Table != nil && r.nftRule.Table.Family == nftables.TableFamilyIPv6 -} - -// isIPv6RouteRule determines whether a route rule belongs to the v6 table. -// For static routes, the destination prefix determines the family. For dynamic -// routes (DomainSet), the sources determine the family since management -// duplicates dynamic rules per family. -func isIPv6RouteRule(sources []netip.Prefix, destination firewall.Network) bool { - if destination.IsPrefix() { - return destination.Prefix.Addr().Is6() - } - return len(sources) > 0 && sources[0].Addr().Is6() -} - -// DeleteRouteRule deletes a routing rule. Route rules live in exactly one -// router; the cached maps are normally authoritative, so the kernel is only -// consulted when neither map knows about the rule. -func (m *Manager) DeleteRouteRule(rule firewall.Rule) error { - m.mutex.Lock() - defer m.mutex.Unlock() - - id := rule.ID() - r, err := m.routerForRuleID(id, (*router).hasRule) + fam, err := m.familyForRuleID(rule.ID(), (*family).hasRule, false) if err != nil { return err } - return r.DeleteRouteRule(rule) + return fam.DeleteFilterRule(rule) } -// routerForRuleID picks the router holding the rule with the given id, using -// the supplied lookup. If the cached maps disagree (or both miss), it refreshes -// from the kernel once and re-checks before falling back to the v4 router. -func (m *Manager) routerForRuleID(id string, has func(*router, string) bool) (*router, error) { - if has(m.router, id) { - return m.router, nil - } - if m.hasIPv6() && has(m.router6, id) { - return m.router6, nil +// familyForRuleID picks the family holding the rule with the given id, using +// the supplied lookup. With refresh set, a miss in both cached maps reloads +// the NAT/DNAT rule maps from the kernel once and re-checks before falling +// back to the v4 family. Filter rules are tracked only in memory and have no +// kernel-backed reload, so their callers pass refresh as false. +func (m *Manager) familyForRuleID(id firewall.RuleID, has func(*family, firewall.RuleID) bool, refresh bool) (*family, error) { + if has(m.family4, id) { + return m.family4, nil } if !m.hasIPv6() { - return m.router, nil + return m.family4, nil } - if err := m.router.refreshRulesMap(); err != nil { + if has(m.family6, id) { + return m.family6, nil + } + if !refresh { + return m.family4, nil + } + if err := m.family4.refreshRulesMap(); err != nil { return nil, fmt.Errorf("refresh v4 rules: %w", err) } - if err := m.router6.refreshRulesMap(); err != nil { + if err := m.family6.refreshRulesMap(); err != nil { return nil, fmt.Errorf("refresh v6 rules: %w", err) } - if has(m.router6, id) && !has(m.router, id) { - return m.router6, nil + if has(m.family6, id) && !has(m.family4, id) { + return m.family6, nil } - return m.router, nil + return m.family4, nil } func (m *Manager) IsServerRouteSupported() bool { @@ -381,10 +313,10 @@ func (m *Manager) AddNatRule(pair firewall.RouterPair) error { if !m.hasIPv6() { return fmt.Errorf("add NAT rule: %w", firewall.ErrIPv6NotInitialized) } - return m.router6.AddNatRule(pair) + return m.family6.AddNatRule(pair) } - if err := m.router.AddNatRule(pair); err != nil { + if err := m.family4.AddNatRule(pair); err != nil { return err } @@ -396,7 +328,7 @@ func (m *Manager) AddNatRule(pair firewall.RouterPair) error { // so the eventual cleanup still works. if m.hasIPv6() && pair.Dynamic { v6Pair := firewall.ToV6NatPair(pair) - if err := m.router6.AddNatRule(v6Pair); err != nil { + if err := m.family6.AddNatRule(v6Pair); err != nil { return fmt.Errorf("add v6 NAT rule: %w", err) } } @@ -412,18 +344,18 @@ func (m *Manager) RemoveNatRule(pair firewall.RouterPair) error { if !m.hasIPv6() { return nil } - return m.router6.RemoveNatRule(pair) + return m.family6.RemoveNatRule(pair) } var merr *multierror.Error - if err := m.router.RemoveNatRule(pair); err != nil { + if err := m.family4.RemoveNatRule(pair); err != nil { merr = multierror.Append(merr, fmt.Errorf("remove v4 NAT rule: %w", err)) } if m.hasIPv6() && pair.Dynamic { v6Pair := firewall.ToV6NatPair(pair) - if err := m.router6.RemoveNatRule(v6Pair); err != nil { + if err := m.family6.RemoveNatRule(v6Pair); err != nil { merr = multierror.Append(merr, fmt.Errorf("remove v6 NAT rule: %w", err)) } } @@ -431,46 +363,13 @@ func (m *Manager) RemoveNatRule(pair firewall.RouterPair) error { return nberrors.FormatErrorOrNil(merr) } -// AllowNetbird allows netbird interface traffic. -// This is called when USPFilter wraps the native firewall, adding blanket accept -// rules so that packet filtering is handled in userspace instead of by netfilter. -// -// TODO: In USP mode this only adds ACCEPT to the netbird table's own chains, -// which doesn't override DROP rules in external tables (e.g. firewalld). -// Should add passthrough rules to external chains (like the native mode router's -// addExternalChainsRules does) for both the netbird table family and inet tables. -// The netbird table itself is fine (routing chains already exist there), but -// non-netbird tables with INPUT/FORWARD hooks can still DROP our WG traffic. -func (m *Manager) AllowNetbird() error { - m.mutex.Lock() - defer m.mutex.Unlock() - - if err := m.aclManager.createDefaultAllowRules(); err != nil { - return fmt.Errorf("create default allow rules: %w", err) - } - if m.hasIPv6() { - if err := m.aclManager6.createDefaultAllowRules(); err != nil { - return fmt.Errorf("create v6 default allow rules: %w", err) - } - } - if err := m.rConn.Flush(); err != nil { - return fmt.Errorf("flush allow input netbird rules: %w", err) - } - - if err := firewalld.TrustInterface(m.wgIface.Name()); err != nil { - log.Warnf("failed to trust interface in firewalld: %v", err) - } - - return nil -} - // SetLegacyManagement sets the route manager to use legacy management func (m *Manager) SetLegacyManagement(isLegacy bool) error { - if err := firewall.SetLegacyManagement(m.router, isLegacy); err != nil { + if err := firewall.SetLegacyManagement(m.family4, isLegacy); err != nil { return err } if m.hasIPv6() { - return firewall.SetLegacyManagement(m.router6, isLegacy) + return firewall.SetLegacyManagement(m.family6, isLegacy) } return nil } @@ -484,13 +383,13 @@ func (m *Manager) Close(stateManager *statemanager.Manager) error { var merr *multierror.Error - if err := m.router.Reset(); err != nil { - merr = multierror.Append(merr, fmt.Errorf("reset router: %v", err)) + if err := m.family4.Reset(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("reset family: %w", err)) } if m.hasIPv6() { - if err := m.router6.Reset(); err != nil { - merr = multierror.Append(merr, fmt.Errorf("reset v6 router: %v", err)) + if err := m.family6.Reset(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("reset v6 family: %w", err)) } } @@ -530,17 +429,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.family4.ipFwdState.RequestRouting(m.hasIPv6()) } 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.family4.ipFwdState.ReleaseRouting() } // Flush rule/chain/set operations from the buffer @@ -551,13 +445,13 @@ func (m *Manager) Flush() error { m.mutex.Lock() defer m.mutex.Unlock() - if err := m.aclManager.Flush(); err != nil { + if err := m.family4.Flush(); err != nil { return err } if m.hasIPv6() { - if err := m.aclManager6.Flush(); err != nil { - return fmt.Errorf("flush v6 acl: %w", err) + if err := m.family6.Flush(); err != nil { + return fmt.Errorf("flush v6 family: %w", err) } } @@ -577,9 +471,9 @@ func (m *Manager) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) if !m.hasIPv6() { return nil, fmt.Errorf("add DNAT rule: %w", firewall.ErrIPv6NotInitialized) } - return m.router6.AddDNATRule(rule) + return m.family6.AddDNATRule(rule) } - return m.router.AddDNATRule(rule) + return m.family4.AddDNATRule(rule) } // DeleteDNATRule deletes a DNAT rule @@ -587,7 +481,7 @@ func (m *Manager) DeleteDNATRule(rule firewall.Rule) error { m.mutex.Lock() defer m.mutex.Unlock() - r, err := m.routerForRuleID(rule.ID(), (*router).hasDNATRule) + r, err := m.familyForRuleID(rule.ID(), (*family).hasDNATRule, true) if err != nil { return err } @@ -608,12 +502,12 @@ func (m *Manager) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error { } } - if err := m.router.UpdateSet(set, v4Prefixes); err != nil { + if err := m.family4.UpdateSet(set, v4Prefixes); err != nil { return err } if m.hasIPv6() && len(v6Prefixes) > 0 { - if err := m.router6.UpdateSet(set, v6Prefixes); err != nil { + if err := m.family6.UpdateSet(set, v6Prefixes); err != nil { return fmt.Errorf("update v6 set: %w", err) } } @@ -630,9 +524,9 @@ func (m *Manager) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protoco if !m.hasIPv6() { return fmt.Errorf("add inbound DNAT: %w", firewall.ErrIPv6NotInitialized) } - return m.router6.AddInboundDNAT(localAddr, protocol, originalPort, translatedPort) + return m.family6.AddInboundDNAT(localAddr, protocol, originalPort, translatedPort) } - return m.router.AddInboundDNAT(localAddr, protocol, originalPort, translatedPort) + return m.family4.AddInboundDNAT(localAddr, protocol, originalPort, translatedPort) } // RemoveInboundDNAT removes an inbound DNAT rule. @@ -644,9 +538,9 @@ func (m *Manager) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Prot if !m.hasIPv6() { return fmt.Errorf("remove inbound DNAT: %w", firewall.ErrIPv6NotInitialized) } - return m.router6.RemoveInboundDNAT(localAddr, protocol, originalPort, translatedPort) + return m.family6.RemoveInboundDNAT(localAddr, protocol, originalPort, translatedPort) } - return m.router.RemoveInboundDNAT(localAddr, protocol, originalPort, translatedPort) + return m.family4.RemoveInboundDNAT(localAddr, protocol, originalPort, translatedPort) } // AddOutputDNAT adds an OUTPUT chain DNAT rule for locally-generated traffic. @@ -658,9 +552,9 @@ func (m *Manager) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol if !m.hasIPv6() { return fmt.Errorf("add output DNAT: %w", firewall.ErrIPv6NotInitialized) } - return m.router6.AddOutputDNAT(localAddr, protocol, originalPort, translatedPort) + return m.family6.AddOutputDNAT(localAddr, protocol, originalPort, translatedPort) } - return m.router.AddOutputDNAT(localAddr, protocol, originalPort, translatedPort) + return m.family4.AddOutputDNAT(localAddr, protocol, originalPort, translatedPort) } // RemoveOutputDNAT removes an OUTPUT chain DNAT rule. @@ -672,9 +566,9 @@ func (m *Manager) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Proto if !m.hasIPv6() { return fmt.Errorf("remove output DNAT: %w", firewall.ErrIPv6NotInitialized) } - return m.router6.RemoveOutputDNAT(localAddr, protocol, originalPort, translatedPort) + return m.family6.RemoveOutputDNAT(localAddr, protocol, originalPort, translatedPort) } - return m.router.RemoveOutputDNAT(localAddr, protocol, originalPort, translatedPort) + return m.family4.RemoveOutputDNAT(localAddr, protocol, originalPort, translatedPort) } const ( @@ -903,3 +797,14 @@ func getEstablishedExprs(register uint32) []expr.Any { }, } } + +// isIPv6Rule reports whether the rule belongs to the v6 table. For a +// prefix destination the destination family decides; otherwise the +// (single-family) sources do, since management duplicates rules per +// family. +func isIPv6Rule(sources []netip.Prefix, destination firewall.Network) bool { + if destination.IsPrefix() { + return destination.Prefix.Addr().Is6() + } + return len(sources) > 0 && sources[0].Addr().Is6() +} diff --git a/client/firewall/nftables/manager_linux_test.go b/client/firewall/nftables/manager_linux_test.go index 4eb466281..0ca56409e 100644 --- a/client/firewall/nftables/manager_linux_test.go +++ b/client/firewall/nftables/manager_linux_test.go @@ -72,13 +72,13 @@ func TestNftablesManager(t *testing.T) { testClient := &nftables.Conn{} - rule, err := manager.AddPeerFiltering(nil, ip.AsSlice(), fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{53}}, fw.ActionDrop, "") + rule, err := manager.AddFilterRule(nil, pfx(ip.AsSlice()), fw.Network{}, fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{53}}, fw.ActionDrop) require.NoError(t, err, "failed to add rule") err = manager.Flush() require.NoError(t, err, "failed to flush") - rules, err := testClient.GetRules(manager.aclManager.workTable, manager.aclManager.chainInputRules) + rules, err := testClient.GetRules(manager.family4.workTable, manager.family4.chainInputRules) require.NoError(t, err, "failed to get rules") require.Len(t, rules, 2, "expected 2 rules") @@ -149,15 +149,12 @@ func TestNftablesManager(t *testing.T) { // Compare connection tracking rule at position 1 (pushed down by DROP rule insertion) compareExprsIgnoringCounters(t, rules[1].Exprs, expectedExprs1) - for _, r := range rule { - err = manager.DeletePeerRule(r) - require.NoError(t, err, "failed to delete rule") - } + require.NoError(t, manager.DeleteFilterRule(rule), "failed to delete rule") err = manager.Flush() require.NoError(t, err, "failed to flush") - rules, err = testClient.GetRules(manager.aclManager.workTable, manager.aclManager.chainInputRules) + rules, err = testClient.GetRules(manager.family4.workTable, manager.family4.chainInputRules) require.NoError(t, err, "failed to get rules") // established rule remains require.Len(t, rules, 1, "expected 1 rules after deletion") @@ -182,47 +179,39 @@ func TestNftablesManagerRuleOrder(t *testing.T) { testClient := &nftables.Conn{} // Add accept rule first - _, err = manager.AddPeerFiltering(nil, ip.AsSlice(), fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionAccept, "accept-http") + _, err = manager.AddFilterRule(nil, pfx(ip.AsSlice()), fw.Network{}, fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionAccept) require.NoError(t, err, "failed to add accept rule") // Add deny rule second for the same traffic - _, err = manager.AddPeerFiltering(nil, ip.AsSlice(), fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionDrop, "deny-http") + _, err = manager.AddFilterRule(nil, pfx(ip.AsSlice()), fw.Network{}, fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionDrop) require.NoError(t, err, "failed to add deny rule") err = manager.Flush() require.NoError(t, err, "failed to flush") - rules, err := testClient.GetRules(manager.aclManager.workTable, manager.aclManager.chainInputRules) + rules, err := testClient.GetRules(manager.family4.workTable, manager.family4.chainInputRules) require.NoError(t, err, "failed to get rules") t.Logf("Found %d rules in nftables chain", len(rules)) - // Find the accept and deny rules and verify deny comes before accept + // Single-source rules emit a direct payload+cmp on the source IP + // (no set lookup). Match by source-IP + port + verdict instead of + // the legacy per-(action,port) set names ("deny-http"/"accept-http") + // that this test predates. + wantSrc := ip.AsSlice() var acceptRuleIndex, denyRuleIndex = -1, -1 for i, rule := range rules { - hasAcceptHTTPSet := false - hasDenyHTTPSet := false - hasPort80 := false + var hasSrc, hasPort80 bool var action string - for _, e := range rule.Exprs { - // Check for set lookup - if lookup, ok := e.(*expr.Lookup); ok { - switch lookup.SetName { - case "accept-http": - hasAcceptHTTPSet = true - case "deny-http": - hasDenyHTTPSet = true + if cmp, ok := e.(*expr.Cmp); ok && cmp.Op == expr.CmpOpEq { + if bytes.Equal(cmp.Data, wantSrc) { + hasSrc = true } - - } - // Check for port 80 - if cmp, ok := e.(*expr.Cmp); ok { - if cmp.Op == expr.CmpOpEq && len(cmp.Data) == 2 && binary.BigEndian.Uint16(cmp.Data) == 80 { + if len(cmp.Data) == 2 && binary.BigEndian.Uint16(cmp.Data) == 80 { hasPort80 = true } } - // Check for verdict if verdict, ok := e.(*expr.Verdict); ok { switch verdict.Kind { case expr.VerdictAccept: @@ -233,11 +222,15 @@ func TestNftablesManagerRuleOrder(t *testing.T) { } } - if hasAcceptHTTPSet && hasPort80 && action == "ACCEPT" { - t.Logf("Rule [%d]: accept-http set + Port 80 + ACCEPT", i) + if !hasSrc || !hasPort80 { + continue + } + switch action { + case "ACCEPT": + t.Logf("Rule [%d]: src=%s port=80 ACCEPT", i, ip) acceptRuleIndex = i - } else if hasDenyHTTPSet && hasPort80 && action == "DROP" { - t.Logf("Rule [%d]: deny-http set + Port 80 + DROP", i) + case "DROP": + t.Logf("Rule [%d]: src=%s port=80 DROP", i, ip) denyRuleIndex = i } } @@ -281,7 +274,7 @@ func TestNFtablesCreatePerformance(t *testing.T) { start := time.Now() for i := 0; i < testMax; i++ { port := &fw.Port{Values: []uint16{uint16(1000 + i)}} - _, err = manager.AddPeerFiltering(nil, ip.AsSlice(), "tcp", nil, port, fw.ActionAccept, "") + _, err = manager.AddFilterRule(nil, pfx(ip.AsSlice()), fw.Network{}, "tcp", nil, port, fw.ActionAccept) require.NoError(t, err, "failed to add rule") if i%100 == 0 { @@ -363,10 +356,10 @@ func TestNftablesManagerCompatibilityWithIptables(t *testing.T) { }) ip := netip.MustParseAddr("100.96.0.1") - _, err = manager.AddPeerFiltering(nil, ip.AsSlice(), fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionAccept, "") + _, err = manager.AddFilterRule(nil, pfx(ip.AsSlice()), fw.Network{}, fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionAccept) require.NoError(t, err, "failed to add peer filtering rule") - _, err = manager.AddRouteFiltering( + _, err = manager.AddFilterRule( nil, []netip.Prefix{netip.MustParsePrefix("192.168.2.0/24")}, fw.Network{Prefix: netip.MustParsePrefix("10.1.0.0/24")}, @@ -439,10 +432,10 @@ func TestNftablesManagerIPv6CompatibilityWithIp6tables(t *testing.T) { }) ip := netip.MustParseAddr("fd00::2") - _, err = manager.AddPeerFiltering(nil, ip.AsSlice(), fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionAccept, "") + _, err = manager.AddFilterRule(nil, pfx(ip.AsSlice()), fw.Network{}, fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionAccept) require.NoError(t, err, "add v6 peer filtering rule") - _, err = manager.AddRouteFiltering( + _, err = manager.AddFilterRule( nil, []netip.Prefix{netip.MustParsePrefix("fd00:1::/64")}, fw.Network{Prefix: netip.MustParsePrefix("2001:db8::/48")}, @@ -552,7 +545,7 @@ func TestNftablesManagerCompatibilityWithIptablesFor6kPrefixes(t *testing.T) { prefixes = append(prefixes, netip.PrefixFrom(addr, 24)) } } - _, err = manager.AddRouteFiltering( + _, err = manager.AddFilterRule( nil, prefixes, fw.Network{Prefix: netip.MustParsePrefix("10.2.0.0/24")}, @@ -567,7 +560,7 @@ func TestNftablesManagerCompatibilityWithIptablesFor6kPrefixes(t *testing.T) { verifyIptablesOutput(t, stdout, stderr) } -func TestNftablesManagerCompatibilityWithIptablesForEmptyPrefixes(t *testing.T) { +func TestNftablesManagerCompatibilityWithIptablesForWildcardSource(t *testing.T) { if check() != NFTABLES { t.Skip("nftables not supported on this system") } @@ -593,9 +586,9 @@ func TestNftablesManagerCompatibilityWithIptablesForEmptyPrefixes(t *testing.T) verifyIptablesOutput(t, stdout, stderr) }) - _, err = manager.AddRouteFiltering( + _, err = manager.AddFilterRule( nil, - []netip.Prefix{}, + []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")}, fw.Network{Prefix: netip.MustParsePrefix("10.2.0.0/24")}, fw.ProtocolTCP, nil, @@ -608,6 +601,73 @@ func TestNftablesManagerCompatibilityWithIptablesForEmptyPrefixes(t *testing.T) verifyIptablesOutput(t, stdout, stderr) } +func TestNftablesManagerMultiPortFilter(t *testing.T) { + if check() != NFTABLES { + t.Skip("nftables not supported on this system") + } + + manager, err := Create(ifaceMock, iface.DefaultMTU) + require.NoError(t, err) + require.NoError(t, manager.Init(nil)) + + t.Cleanup(func() { + require.NoError(t, manager.Close(nil), "failed to reset manager state") + }) + + ip := netip.MustParseAddr("100.96.0.1") + + rule, err := manager.AddFilterRule(nil, pfx(ip.AsSlice()), fw.Network{}, fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80, 443}}, fw.ActionAccept) + require.NoError(t, err, "failed to add multi-port rule") + + testClient := &nftables.Conn{} + rules, err := testClient.GetRules(manager.family4.workTable, manager.family4.chainInputRules) + require.NoError(t, err, "failed to get rules") + + var lookup *expr.Lookup + for _, kernelRule := range rules { + if string(kernelRule.UserData) != string(rule.ID()) { + continue + } + for _, e := range kernelRule.Exprs { + if l, ok := e.(*expr.Lookup); ok { + lookup = l + } + } + } + require.NotNil(t, lookup, "multi-port rule must match ports via a set lookup") + + sets, err := testClient.GetSets(manager.family4.workTable) + require.NoError(t, err, "failed to get sets") + + var portSet *nftables.Set + for _, s := range sets { + if s.Name == lookup.SetName { + portSet = s + } + } + require.NotNil(t, portSet, "anonymous port set not found in kernel") + + portSet.Table = manager.family4.workTable + elements, err := testClient.GetSetElements(portSet) + require.NoError(t, err, "failed to get set elements") + + ports := make(map[uint16]bool) + for _, e := range elements { + require.Len(t, e.Key, 2, "port set element key should be 2 bytes") + ports[binary.BigEndian.Uint16(e.Key)] = true + } + require.True(t, ports[80], "port set should contain port 80") + require.True(t, ports[443], "port set should contain port 443") + + require.NoError(t, manager.DeleteFilterRule(rule), "failed to delete rule") + + rules, err = testClient.GetRules(manager.family4.workTable, manager.family4.chainInputRules) + require.NoError(t, err, "failed to get rules after delete") + for _, kernelRule := range rules { + require.NotEqual(t, string(rule.ID()), string(kernelRule.UserData), "rule should be removed from kernel") + } +} + func compareExprsIgnoringCounters(t *testing.T, got, want []expr.Any) { t.Helper() require.Equal(t, len(got), len(want), "expression count mismatch") diff --git a/client/firewall/nftables/router_linux.go b/client/firewall/nftables/router_linux.go deleted file mode 100644 index 4214455a9..000000000 --- a/client/firewall/nftables/router_linux.go +++ /dev/null @@ -1,2244 +0,0 @@ -package nftables - -import ( - "bytes" - "encoding/binary" - "errors" - "fmt" - "net" - "net/netip" - "strings" - - "github.com/coreos/go-iptables/iptables" - "github.com/google/nftables" - "github.com/google/nftables/binaryutil" - "github.com/google/nftables/expr" - "github.com/google/nftables/xt" - "github.com/hashicorp/go-multierror" - log "github.com/sirupsen/logrus" - "golang.org/x/sys/unix" - - nberrors "github.com/netbirdio/netbird/client/errors" - "github.com/netbirdio/netbird/client/firewall/firewalld" - firewall "github.com/netbirdio/netbird/client/firewall/manager" - nbid "github.com/netbirdio/netbird/client/internal/acl/id" - "github.com/netbirdio/netbird/client/internal/routemanager/ipfwdstate" - "github.com/netbirdio/netbird/client/internal/routemanager/refcounter" - nbnet "github.com/netbirdio/netbird/client/net" -) - -const ( - tableNat = "nat" - tableMangle = "mangle" - tableRaw = "raw" - tableSecurity = "security" - - chainNameNatPrerouting = "PREROUTING" - chainNameRoutingFw = "netbird-rt-fwd" - chainNameRoutingNat = "netbird-rt-postrouting" - chainNameRoutingRdr = "netbird-rt-redirect" - chainNameNATOutput = "netbird-nat-output" - chainNameForward = "FORWARD" - chainNameMangleForward = "netbird-mangle-forward" - - firewalldTableName = "firewalld" - - userDataAcceptForwardRuleIif = "frwacceptiif" - userDataAcceptForwardRuleOif = "frwacceptoif" - userDataAcceptInputRule = "inputaccept" - - dnatSuffix = "_dnat" - snatSuffix = "_snat" - - // ipv4TCPHeaderSize is the minimum IPv4 (20) + TCP (20) header size for MSS calculation. - ipv4TCPHeaderSize = 40 - // ipv6TCPHeaderSize is the minimum IPv6 (40) + TCP (20) header size for MSS calculation. - ipv6TCPHeaderSize = 60 - - // maxPrefixesSet 1638 prefixes start to fail, taking some margin - maxPrefixesSet = 1500 - refreshRulesMapError = "refresh rules map: %w" -) - -var ( - errFilterTableNotFound = fmt.Errorf("'filter' table not found") -) - -type setInput struct { - set firewall.Set - prefixes []netip.Prefix -} - -type router struct { - conn *nftables.Conn - workTable *nftables.Table - filterTable *nftables.Table - chains map[string]*nftables.Chain - // rules is useful to avoid duplicates and to get missing attributes that we don't have when adding new rules - rules map[string]*nftables.Rule - ipsetCounter *refcounter.Counter[string, setInput, *nftables.Set] - - af addrFamily - wgIface iFaceMapper - ipFwdState *ipfwdstate.IPForwardingState - legacyManagement bool - mtu uint16 -} - -func newRouter(workTable *nftables.Table, wgIface iFaceMapper, mtu uint16) (*router, error) { - r := &router{ - conn: &nftables.Conn{}, - workTable: workTable, - chains: make(map[string]*nftables.Chain), - rules: make(map[string]*nftables.Rule), - af: familyForAddr(workTable.Family == nftables.TableFamilyIPv4), - wgIface: wgIface, - ipFwdState: ipfwdstate.NewIPForwardingState(), - mtu: mtu, - } - - r.ipsetCounter = refcounter.New( - r.createIpSet, - r.deleteIpSet, - ) - - var err error - r.filterTable, err = r.loadFilterTable() - if err != nil { - log.Debugf("ip filter table not found: %v", err) - } - - return r, nil -} - -func (r *router) init(workTable *nftables.Table) error { - r.workTable = workTable - - if err := r.removeAcceptFilterRules(); err != nil { - log.Errorf("failed to clean up rules from filter table: %s", err) - } - - if err := r.createContainers(); err != nil { - return fmt.Errorf("create containers: %w", err) - } - - if err := r.setupDataPlaneMark(); err != nil { - log.Errorf("failed to set up data plane mark: %v", err) - } - - return nil -} - -// Reset cleans existing nftables filter table rules from the system -func (r *router) Reset() error { - // clear without deleting the ipsets, the nf table will be deleted by the caller - r.ipsetCounter.Clear() - - var merr *multierror.Error - - if err := r.removeAcceptFilterRules(); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove accept filter rules: %w", err)) - } - - if err := firewalld.UntrustInterface(r.wgIface.Name()); err != nil { - merr = multierror.Append(merr, err) - } - - if err := r.removeNatPreroutingRules(); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove filter prerouting rules: %w", err)) - } - - return nberrors.FormatErrorOrNil(merr) -} - -func (r *router) removeNatPreroutingRules() error { - table := &nftables.Table{ - Name: tableNat, - Family: r.af.tableFamily, - } - chain := &nftables.Chain{ - Name: chainNameNatPrerouting, - Table: table, - Hooknum: nftables.ChainHookPrerouting, - Priority: nftables.ChainPriorityNATDest, - Type: nftables.ChainTypeNAT, - } - rules, err := r.conn.GetRules(table, chain) - if err != nil { - return fmt.Errorf("get rules from nat table: %w", err) - } - - var merr *multierror.Error - - // Delete rules that have our UserData suffix - for _, rule := range rules { - if len(rule.UserData) == 0 || !strings.HasSuffix(string(rule.UserData), dnatSuffix) { - continue - } - if err := r.conn.DelRule(rule); err != nil { - merr = multierror.Append(merr, fmt.Errorf("delete rule %s: %w", rule.UserData, err)) - } - } - - if err := r.conn.Flush(); err != nil { - merr = multierror.Append(merr, fmt.Errorf(flushError, err)) - } - return nberrors.FormatErrorOrNil(merr) -} - -func (r *router) loadFilterTable() (*nftables.Table, error) { - tables, err := r.conn.ListTablesOfFamily(r.af.tableFamily) - if err != nil { - return nil, fmt.Errorf("list tables: %w", err) - } - - for _, table := range tables { - if table.Name == "filter" { - return table, nil - } - } - - return nil, errFilterTableNotFound -} - -func hookName(hook *nftables.ChainHook) string { - if hook == nil { - return "unknown" - } - switch *hook { - case *nftables.ChainHookForward: - return chainNameForward - case *nftables.ChainHookInput: - return chainNameInput - default: - return fmt.Sprintf("hook(%d)", *hook) - } -} - -func familyName(family nftables.TableFamily) string { - switch family { - case nftables.TableFamilyIPv4: - return "ip" - case nftables.TableFamilyIPv6: - return "ip6" - case nftables.TableFamilyINet: - return "inet" - default: - return fmt.Sprintf("family(%d)", family) - } -} - -func (r *router) createContainers() error { - r.chains[chainNameRoutingFw] = r.conn.AddChain(&nftables.Chain{ - Name: chainNameRoutingFw, - Table: r.workTable, - }) - - prio := *nftables.ChainPriorityNATSource - 1 - r.chains[chainNameRoutingNat] = r.conn.AddChain(&nftables.Chain{ - Name: chainNameRoutingNat, - Table: r.workTable, - Hooknum: nftables.ChainHookPostrouting, - Priority: &prio, - Type: nftables.ChainTypeNAT, - }) - - r.chains[chainNameRoutingRdr] = r.conn.AddChain(&nftables.Chain{ - Name: chainNameRoutingRdr, - Table: r.workTable, - Hooknum: nftables.ChainHookPrerouting, - Priority: nftables.ChainPriorityNATDest, - Type: nftables.ChainTypeNAT, - }) - - r.chains[chainNameManglePostrouting] = r.conn.AddChain(&nftables.Chain{ - Name: chainNameManglePostrouting, - Table: r.workTable, - Hooknum: nftables.ChainHookPostrouting, - Priority: nftables.ChainPriorityMangle, - Type: nftables.ChainTypeFilter, - }) - - r.chains[chainNameManglePrerouting] = r.conn.AddChain(&nftables.Chain{ - Name: chainNameManglePrerouting, - Table: r.workTable, - Hooknum: nftables.ChainHookPrerouting, - Priority: nftables.ChainPriorityMangle, - Type: nftables.ChainTypeFilter, - }) - - r.chains[chainNameMangleForward] = r.conn.AddChain(&nftables.Chain{ - Name: chainNameMangleForward, - Table: r.workTable, - Hooknum: nftables.ChainHookForward, - Priority: nftables.ChainPriorityMangle, - Type: nftables.ChainTypeFilter, - }) - - insertReturnTrafficRule(r.conn, r.workTable, r.chains[chainNameRoutingFw]) - - r.addPostroutingRules() - - if err := r.conn.Flush(); err != nil { - return fmt.Errorf("initialize tables: %v", err) - } - - if err := r.addMSSClampingRules(); err != nil { - log.Errorf("failed to add MSS clamping rules: %s", err) - } - - if err := r.acceptForwardRules(); err != nil { - log.Errorf("failed to add accept rules for the forward chain: %s", err) - } - - if err := firewalld.TrustInterface(r.wgIface.Name()); err != nil { - log.Warnf("failed to trust interface in firewalld: %v", err) - } - - if err := r.refreshRulesMap(); err != nil { - log.Errorf("failed to refresh rules: %s", err) - } - - return nil -} - -// setupDataPlaneMark configures the fwmark for the data plane -func (r *router) setupDataPlaneMark() error { - if r.chains[chainNameManglePrerouting] == nil || r.chains[chainNameManglePostrouting] == nil { - return errors.New("no mangle chains found") - } - - ctNew := getCtNewExprs() - preExprs := []expr.Any{ - &expr.Meta{ - Key: expr.MetaKeyIIFNAME, - Register: 1, - }, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: ifname(r.wgIface.Name()), - }, - } - preExprs = append(preExprs, ctNew...) - preExprs = append(preExprs, - &expr.Immediate{ - Register: 1, - Data: binaryutil.NativeEndian.PutUint32(nbnet.DataPlaneMarkIn), - }, - &expr.Ct{ - Key: expr.CtKeyMARK, - Register: 1, - SourceRegister: true, - }, - ) - - preNftRule := &nftables.Rule{ - Table: r.workTable, - Chain: r.chains[chainNameManglePrerouting], - Exprs: preExprs, - } - r.conn.AddRule(preNftRule) - - postExprs := []expr.Any{ - &expr.Meta{ - Key: expr.MetaKeyOIFNAME, - Register: 1, - }, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: ifname(r.wgIface.Name()), - }, - } - postExprs = append(postExprs, ctNew...) - postExprs = append(postExprs, - &expr.Immediate{ - Register: 1, - Data: binaryutil.NativeEndian.PutUint32(nbnet.DataPlaneMarkOut), - }, - &expr.Ct{ - Key: expr.CtKeyMARK, - Register: 1, - SourceRegister: true, - }, - ) - - postNftRule := &nftables.Rule{ - Table: r.workTable, - Chain: r.chains[chainNameManglePostrouting], - Exprs: postExprs, - } - r.conn.AddRule(postNftRule) - - if err := r.conn.Flush(); err != nil { - return fmt.Errorf("flush: %w", err) - } - - return nil -} - -// AddRouteFiltering appends a nftables rule to the routing chain -func (r *router) AddRouteFiltering( - id []byte, - sources []netip.Prefix, - destination firewall.Network, - proto firewall.Protocol, - sPort *firewall.Port, - dPort *firewall.Port, - action firewall.Action, -) (firewall.Rule, error) { - - ruleKey := nbid.GenerateRouteRuleKey(sources, destination, proto, sPort, dPort, action) - if _, ok := r.rules[string(ruleKey)]; ok { - return ruleKey, nil - } - - chain := r.chains[chainNameRoutingFw] - var exprs []expr.Any - - var source firewall.Network - switch { - case len(sources) == 1 && sources[0].Bits() == 0: - // If it's 0.0.0.0/0, we don't need to add any source matching - case len(sources) == 1: - // If there's only one source, we can use it directly - source.Prefix = sources[0] - default: - // If there are multiple sources, use a set - source.Set = firewall.NewPrefixSet(sources) - } - - sourceExp, err := r.applyNetwork(source, sources, true) - if err != nil { - return nil, fmt.Errorf("apply source: %w", err) - } - exprs = append(exprs, sourceExp...) - - destExp, err := r.applyNetwork(destination, nil, false) - if err != nil { - return nil, fmt.Errorf("apply destination: %w", err) - } - exprs = append(exprs, destExp...) - - // Handle protocol - if proto != firewall.ProtocolALL { - protoNum, err := r.af.protoNum(proto) - if err != nil { - return nil, fmt.Errorf("convert protocol to number: %w", err) - } - exprs = append(exprs, &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}) - exprs = append(exprs, &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: []byte{protoNum}, - }) - - exprs = append(exprs, applyPort(sPort, true)...) - exprs = append(exprs, applyPort(dPort, false)...) - } - - exprs = append(exprs, &expr.Counter{}) - - var verdict expr.VerdictKind - if action == firewall.ActionAccept { - verdict = expr.VerdictAccept - } else { - verdict = expr.VerdictDrop - } - exprs = append(exprs, &expr.Verdict{Kind: verdict}) - - rule := &nftables.Rule{ - Table: r.workTable, - Chain: chain, - Exprs: exprs, - UserData: []byte(ruleKey), - } - - // Insert DROP rules at the beginning, append ACCEPT rules at the end - if action == firewall.ActionDrop { - // TODO: Insert after the established rule - rule = r.conn.InsertRule(rule) - } else { - rule = r.conn.AddRule(rule) - } - - if err := r.conn.Flush(); err != nil { - return nil, fmt.Errorf(flushError, err) - } - - r.rules[string(ruleKey)] = rule - - log.Debugf("added route rule: sources=%v, destination=%v, proto=%v, sPort=%v, dPort=%v, action=%v", sources, destination, proto, sPort, dPort, action) - - return ruleKey, nil -} - -func (r *router) getIpSet(set firewall.Set, prefixes []netip.Prefix, isSource bool) ([]expr.Any, error) { - ref, err := r.ipsetCounter.Increment(set.HashedName(), setInput{ - set: set, - prefixes: prefixes, - }) - if err != nil { - return nil, fmt.Errorf("create or get ipset: %w", err) - } - - return r.getIpSetExprs(ref, isSource) -} - -func (r *router) iptablesProto() iptables.Protocol { - if r.af.tableFamily == nftables.TableFamilyIPv6 { - return iptables.ProtocolIPv6 - } - return iptables.ProtocolIPv4 -} - -func (r *router) hasRule(id string) bool { - _, ok := r.rules[id] - return ok -} - -func (r *router) hasDNATRule(id string) bool { - _, ok := r.rules[id+dnatSuffix] - return ok -} - -func (r *router) DeleteRouteRule(rule firewall.Rule) error { - if err := r.refreshRulesMap(); err != nil { - return fmt.Errorf(refreshRulesMapError, err) - } - - ruleKey := rule.ID() - nftRule, exists := r.rules[ruleKey] - if !exists { - log.Debugf("route rule %s not found", ruleKey) - return nil - } - - if nftRule.Handle == 0 { - log.Warnf("route rule %s has no handle, removing stale entry", ruleKey) - if err := r.decrementSetCounter(nftRule); err != nil { - log.Warnf("decrement set counter for stale rule %s: %v", ruleKey, err) - } - delete(r.rules, ruleKey) - return nil - } - - if err := r.deleteNftRule(nftRule, ruleKey); err != nil { - return fmt.Errorf("delete: %w", err) - } - - if err := r.conn.Flush(); err != nil { - return fmt.Errorf(flushError, err) - } - - if err := r.decrementSetCounter(nftRule); err != nil { - return fmt.Errorf("decrement set counter: %w", err) - } - - return nil -} - -func (r *router) createIpSet(setName string, input setInput) (*nftables.Set, error) { - // overlapping prefixes will result in an error, so we need to merge them - prefixes := firewall.MergeIPRanges(input.prefixes) - - nfset := &nftables.Set{ - Name: setName, - Comment: input.set.Comment(), - Table: r.workTable, - // required for prefixes - Interval: true, - KeyType: r.af.setKeyType, - } - - elements := r.convertPrefixesToSet(prefixes) - nElements := len(elements) - - maxElements := maxPrefixesSet * 2 - initialElements := elements[:min(maxElements, nElements)] - - if err := r.conn.AddSet(nfset, initialElements); err != nil { - return nil, fmt.Errorf("error adding set %s: %w", setName, err) - } - if err := r.conn.Flush(); err != nil { - return nil, fmt.Errorf("flush error: %w", err) - } - log.Debugf("Created new ipset: %s with %d initial prefixes (total prefixes %d)", setName, len(initialElements)/2, len(prefixes)) - - var subEnd int - for subStart := maxElements; subStart < nElements; subStart += maxElements { - subEnd = min(subStart+maxElements, nElements) - subElement := elements[subStart:subEnd] - nSubPrefixes := len(subElement) / 2 - log.Tracef("Adding new prefixes (%d) in ipset: %s", nSubPrefixes, setName) - if err := r.conn.SetAddElements(nfset, subElement); err != nil { - return nil, fmt.Errorf("error adding prefixes (%d) to set %s: %w", nSubPrefixes, setName, err) - } - if err := r.conn.Flush(); err != nil { - return nil, fmt.Errorf("flush error: %w", err) - } - log.Debugf("Added new prefixes (%d) in ipset: %s", nSubPrefixes, setName) - } - - log.Infof("Created new ipset: %s with %d prefixes", setName, len(prefixes)) - return nfset, nil -} - -func (r *router) convertPrefixesToSet(prefixes []netip.Prefix) []nftables.SetElement { - var elements []nftables.SetElement - for _, prefix := range prefixes { - // nftables needs half-open intervals [firstIP, lastIP) for prefixes - // e.g. 10.0.0.0/24 becomes [10.0.0.0, 10.0.1.0), 10.1.1.1/32 becomes [10.1.1.1, 10.1.1.2) etc - firstIP := prefix.Addr() - lastIP := calculateLastIP(prefix).Next() - - elements = append(elements, - // the nft tool also adds a zero-address IntervalEnd element, see https://github.com/google/nftables/issues/247 - // nftables.SetElement{Key: make([]byte, r.af.addrLen), IntervalEnd: true}, - nftables.SetElement{Key: firstIP.AsSlice()}, - nftables.SetElement{Key: lastIP.AsSlice(), IntervalEnd: true}, - ) - } - return elements -} - -// calculateLastIP determines the last IP in a given prefix. -func calculateLastIP(prefix netip.Prefix) netip.Addr { - masked := prefix.Masked() - if masked.Addr().Is4() { - hostMask := ^uint32(0) >> masked.Bits() - lastIP := uint32FromNetipAddr(masked.Addr()) | hostMask - return netip.AddrFrom4(uint32ToBytes(lastIP)) - } - - // IPv6: set host bits to all 1s - b := masked.Addr().As16() - bits := masked.Bits() - for i := bits; i < 128; i++ { - b[i/8] |= 1 << (7 - i%8) - } - return netip.AddrFrom16(b) -} - -// Utility function to convert netip.Addr to uint32. -func uint32FromNetipAddr(addr netip.Addr) uint32 { - b := addr.As4() - return binary.BigEndian.Uint32(b[:]) -} - -// Utility function to convert uint32 to a netip-compatible byte slice. -func uint32ToBytes(ip uint32) [4]byte { - var b [4]byte - binary.BigEndian.PutUint32(b[:], ip) - return b -} - -func (r *router) deleteIpSet(setName string, nfset *nftables.Set) error { - r.conn.DelSet(nfset) - if err := r.conn.Flush(); err != nil { - return fmt.Errorf(flushError, err) - } - - log.Debugf("Deleted unused ipset %s", setName) - return nil -} - -func (r *router) decrementSetCounter(rule *nftables.Rule) error { - sets := r.findSets(rule) - - var merr *multierror.Error - for _, setName := range sets { - if _, err := r.ipsetCounter.Decrement(setName); err != nil { - merr = multierror.Append(merr, fmt.Errorf("decrement set counter: %w", err)) - } - } - - return nberrors.FormatErrorOrNil(merr) -} - -func (r *router) findSets(rule *nftables.Rule) []string { - var sets []string - for _, e := range rule.Exprs { - if lookup, ok := e.(*expr.Lookup); ok { - sets = append(sets, lookup.SetName) - } - } - return sets -} - -func (r *router) deleteNftRule(rule *nftables.Rule, ruleKey string) error { - if err := r.conn.DelRule(rule); err != nil { - return fmt.Errorf("delete rule %s: %w", ruleKey, err) - } - delete(r.rules, ruleKey) - - log.Debugf("removed route rule %s", ruleKey) - - return nil -} - -// AddNatRule appends a nftables rule pair to the nat chain -func (r *router) AddNatRule(pair firewall.RouterPair) error { - if err := r.refreshRulesMap(); err != nil { - return fmt.Errorf(refreshRulesMapError, err) - } - - if r.legacyManagement { - log.Warnf("This peer is connected to a NetBird Management service with an older version. Allowing all traffic for %s", pair.Destination) - if err := r.addLegacyRouteRule(pair); err != nil { - return fmt.Errorf("add legacy routing rule: %w", err) - } - } - - if pair.Masquerade { - if err := r.addNatRule(pair); err != nil { - return fmt.Errorf("add nat rule: %w", err) - } - - if err := r.addNatRule(firewall.GetInversePair(pair)); err != nil { - return fmt.Errorf("add inverse nat rule: %w", err) - } - } - - if err := r.conn.Flush(); err != nil { - r.rollbackRules(pair) - return fmt.Errorf("insert rules for %s: %w", pair.Destination, err) - } - - return nil -} - -// rollbackRules cleans up unflushed rules and their set counters after a flush failure. -func (r *router) rollbackRules(pair firewall.RouterPair) { - keys := []string{ - firewall.GenKey(firewall.ForwardingFormat, pair), - firewall.GenKey(firewall.PreroutingFormat, pair), - firewall.GenKey(firewall.PreroutingFormat, firewall.GetInversePair(pair)), - } - for _, key := range keys { - rule, ok := r.rules[key] - if !ok { - continue - } - if err := r.decrementSetCounter(rule); err != nil { - log.Warnf("rollback set counter for %s: %v", key, err) - } - delete(r.rules, key) - } -} - -// addNatRule inserts a nftables rule to the conn client flush queue -func (r *router) addNatRule(pair firewall.RouterPair) error { - sourceExp, err := r.applyNetwork(pair.Source, nil, true) - if err != nil { - return fmt.Errorf("apply source: %w", err) - } - - destExp, err := r.applyNetwork(pair.Destination, nil, false) - if err != nil { - return fmt.Errorf("apply destination: %w", err) - } - - op := expr.CmpOpEq - if pair.Inverse { - op = expr.CmpOpNeq - } - - exprs := []expr.Any{ - &expr.Meta{ - Key: expr.MetaKeyIIFNAME, - Register: 1, - }, - &expr.Cmp{ - Op: op, - Register: 1, - Data: ifname(r.wgIface.Name()), - }, - } - // We only care about NEW connections to mark them and later identify them in the postrouting chain for masquerading. - // Masquerading will take care of the conntrack state, which means we won't need to mark established connections. - exprs = append(exprs, getCtNewExprs()...) - - exprs = append(exprs, sourceExp...) - exprs = append(exprs, destExp...) - - var markValue uint32 = nbnet.PreroutingFwmarkMasquerade - if pair.Inverse { - markValue = nbnet.PreroutingFwmarkMasqueradeReturn - } - - exprs = append(exprs, - &expr.Immediate{ - Register: 1, - Data: binaryutil.NativeEndian.PutUint32(markValue), - }, - &expr.Meta{ - Key: expr.MetaKeyMARK, - SourceRegister: true, - Register: 1, - }, - ) - - ruleKey := firewall.GenKey(firewall.PreroutingFormat, pair) - - if _, exists := r.rules[ruleKey]; exists { - if err := r.removeNatRule(pair); err != nil { - return fmt.Errorf("remove prerouting rule: %w", err) - } - } - - // Ensure nat rules come first, so the mark can be overwritten. - // Currently overwritten by the dst-type LOCAL rules for redirected traffic. - r.rules[ruleKey] = r.conn.InsertRule(&nftables.Rule{ - Table: r.workTable, - Chain: r.chains[chainNameManglePrerouting], - Exprs: exprs, - UserData: []byte(ruleKey), - }) - - return nil -} - -// addPostroutingRules adds the masquerade rules -func (r *router) addPostroutingRules() { - // First masquerade rule for traffic coming in from WireGuard interface - exprs := []expr.Any{ - // Match on the first fwmark - &expr.Meta{ - Key: expr.MetaKeyMARK, - Register: 1, - }, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: binaryutil.NativeEndian.PutUint32(nbnet.PreroutingFwmarkMasquerade), - }, - - // We need to exclude the loopback interface as this changes the ebpf proxy port - &expr.Meta{ - Key: expr.MetaKeyOIFNAME, - Register: 1, - }, - &expr.Cmp{ - Op: expr.CmpOpNeq, - Register: 1, - Data: ifname("lo"), - }, - &expr.Counter{}, - &expr.Masq{}, - } - - r.conn.AddRule(&nftables.Rule{ - Table: r.workTable, - Chain: r.chains[chainNameRoutingNat], - Exprs: exprs, - }) - - // Second masquerade rule for traffic going out through WireGuard interface - exprs2 := []expr.Any{ - // Match on the second fwmark - &expr.Meta{ - Key: expr.MetaKeyMARK, - Register: 1, - }, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: binaryutil.NativeEndian.PutUint32(nbnet.PreroutingFwmarkMasqueradeReturn), - }, - - // Match WireGuard interface - &expr.Meta{ - Key: expr.MetaKeyOIFNAME, - Register: 1, - }, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: ifname(r.wgIface.Name()), - }, - &expr.Counter{}, - &expr.Masq{}, - } - - r.conn.AddRule(&nftables.Rule{ - Table: r.workTable, - Chain: r.chains[chainNameRoutingNat], - Exprs: exprs2, - }) -} - -// addMSSClampingRules adds MSS clamping rules to prevent fragmentation for forwarded traffic. -func (r *router) addMSSClampingRules() error { - overhead := uint16(ipv4TCPHeaderSize) - if r.af.tableFamily == nftables.TableFamilyIPv6 { - overhead = ipv6TCPHeaderSize - } - if r.mtu <= overhead { - log.Debugf("MTU %d too small for MSS clamping (overhead %d), skipping", r.mtu, overhead) - return nil - } - mss := r.mtu - overhead - - exprsOut := []expr.Any{ - &expr.Meta{ - Key: expr.MetaKeyOIFNAME, - Register: 1, - }, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: ifname(r.wgIface.Name()), - }, - &expr.Meta{ - Key: expr.MetaKeyL4PROTO, - Register: 1, - }, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: []byte{unix.IPPROTO_TCP}, - }, - &expr.Payload{ - DestRegister: 1, - Base: expr.PayloadBaseTransportHeader, - Offset: 13, - Len: 1, - }, - &expr.Bitwise{ - DestRegister: 1, - SourceRegister: 1, - Len: 1, - Mask: []byte{0x02}, - Xor: []byte{0x00}, - }, - &expr.Cmp{ - Op: expr.CmpOpNeq, - Register: 1, - Data: []byte{0x00}, - }, - &expr.Counter{}, - &expr.Exthdr{ - DestRegister: 1, - Type: 2, - Offset: 2, - Len: 2, - Op: expr.ExthdrOpTcpopt, - }, - &expr.Cmp{ - Op: expr.CmpOpGt, - Register: 1, - Data: binaryutil.BigEndian.PutUint16(uint16(mss)), - }, - &expr.Immediate{ - Register: 1, - Data: binaryutil.BigEndian.PutUint16(uint16(mss)), - }, - &expr.Exthdr{ - SourceRegister: 1, - Type: 2, - Offset: 2, - Len: 2, - Op: expr.ExthdrOpTcpopt, - }, - } - - r.conn.AddRule(&nftables.Rule{ - Table: r.workTable, - Chain: r.chains[chainNameMangleForward], - Exprs: exprsOut, - }) - - return r.conn.Flush() -} - -// addLegacyRouteRule adds a legacy routing rule for mgmt servers pre route acls -func (r *router) addLegacyRouteRule(pair firewall.RouterPair) error { - sourceExp, err := r.applyNetwork(pair.Source, nil, true) - if err != nil { - return fmt.Errorf("apply source: %w", err) - } - - destExp, err := r.applyNetwork(pair.Destination, nil, false) - if err != nil { - return fmt.Errorf("apply destination: %w", err) - } - - exprs := []expr.Any{ - &expr.Counter{}, - &expr.Verdict{ - Kind: expr.VerdictAccept, - }, - } - - exprs = append(exprs, sourceExp...) - exprs = append(exprs, destExp...) - - ruleKey := firewall.GenKey(firewall.ForwardingFormat, pair) - - if _, exists := r.rules[ruleKey]; exists { - if err := r.removeLegacyRouteRule(pair); err != nil { - return fmt.Errorf("remove legacy routing rule: %w", err) - } - } - - r.rules[ruleKey] = r.conn.AddRule(&nftables.Rule{ - Table: r.workTable, - Chain: r.chains[chainNameRoutingFw], - Exprs: exprs, - UserData: []byte(ruleKey), - }) - return nil -} - -// removeLegacyRouteRule removes a legacy routing rule for mgmt servers pre route acls -func (r *router) removeLegacyRouteRule(pair firewall.RouterPair) error { - ruleKey := firewall.GenKey(firewall.ForwardingFormat, pair) - - rule, exists := r.rules[ruleKey] - if !exists { - return nil - } - - if rule.Handle == 0 { - log.Warnf("legacy forwarding rule %s has no handle, removing stale entry", ruleKey) - if err := r.decrementSetCounter(rule); err != nil { - log.Warnf("decrement set counter for stale rule %s: %v", ruleKey, err) - } - delete(r.rules, ruleKey) - return nil - } - - if err := r.conn.DelRule(rule); err != nil { - return fmt.Errorf("remove legacy forwarding rule %s -> %s: %w", pair.Source, pair.Destination, err) - } - - log.Debugf("removed legacy forwarding rule %s -> %s", pair.Source, pair.Destination) - - delete(r.rules, ruleKey) - - if err := r.decrementSetCounter(rule); err != nil { - return fmt.Errorf("decrement set counter: %w", err) - } - - return nil -} - -// GetLegacyManagement returns the route manager's legacy management mode -func (r *router) GetLegacyManagement() bool { - return r.legacyManagement -} - -// SetLegacyManagement sets the route manager to use legacy management mode -func (r *router) SetLegacyManagement(isLegacy bool) { - r.legacyManagement = isLegacy -} - -// RemoveAllLegacyRouteRules removes all legacy routing rules for mgmt servers pre route acls -func (r *router) RemoveAllLegacyRouteRules() error { - if err := r.refreshRulesMap(); err != nil { - return fmt.Errorf(refreshRulesMapError, err) - } - - var merr *multierror.Error - for k, rule := range r.rules { - if !strings.HasPrefix(k, firewall.ForwardingFormatPrefix) { - continue - } - if err := r.conn.DelRule(rule); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove legacy forwarding rule: %v", err)) - } else { - delete(r.rules, k) - } - - } - return nberrors.FormatErrorOrNil(merr) -} - -// acceptForwardRules adds iif/oif rules in the filter table/forward chain to make sure -// that our traffic is not dropped by existing rules there. -// The existing FORWARD rules/policies decide outbound traffic towards our interface. -// In case the FORWARD policy is set to "drop", we add an established/related rule to allow return traffic for the inbound rule. -// This method also adds INPUT chain rules to allow traffic to the local interface. -func (r *router) acceptForwardRules() error { - var merr *multierror.Error - - if err := r.acceptFilterTableRules(); err != nil { - merr = multierror.Append(merr, err) - } - - if err := r.acceptExternalChainsRules(); err != nil { - merr = multierror.Append(merr, fmt.Errorf("add accept rules to external chains: %w", err)) - } - - return nberrors.FormatErrorOrNil(merr) -} - -func (r *router) acceptFilterTableRules() error { - if r.filterTable == nil { - return nil - } - - fw := "iptables" - - defer func() { - log.Debugf("Used %s to add accept forward and input rules", fw) - }() - - // Try iptables first and fallback to nftables if iptables is not available. - // Use the correct protocol (iptables vs ip6tables) for the address family. - ipt, err := iptables.NewWithProtocol(r.iptablesProto()) - if err != nil { - log.Warnf("Will use nftables to manipulate the filter table because iptables is not available: %v", err) - - fw = "nftables" - return r.acceptFilterRulesNftables(r.filterTable) - } - - if err := r.acceptFilterRulesIptables(ipt); err != nil { - log.Warnf("iptables failed (table may be incompatible), falling back to nftables: %v", err) - fw = "nftables" - return r.acceptFilterRulesNftables(r.filterTable) - } - return nil -} - -func (r *router) acceptFilterRulesIptables(ipt *iptables.IPTables) error { - var merr *multierror.Error - - for _, rule := range r.getAcceptForwardRules() { - if err := ipt.Insert("filter", chainNameForward, 1, rule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("add iptables forward rule: %v", err)) - } else { - log.Debugf("added iptables forward rule: %v", rule) - } - } - - inputRule := r.getAcceptInputRule() - if err := ipt.Insert("filter", chainNameInput, 1, inputRule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("add iptables input rule: %v", err)) - } else { - log.Debugf("added iptables input rule: %v", inputRule) - } - - return nberrors.FormatErrorOrNil(merr) -} - -func (r *router) getAcceptForwardRules() [][]string { - intf := r.wgIface.Name() - return [][]string{ - {"-i", intf, "-j", "ACCEPT"}, - {"-o", intf, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "ACCEPT"}, - } -} - -func (r *router) getAcceptInputRule() []string { - return []string{"-i", r.wgIface.Name(), "-j", "ACCEPT"} -} - -// acceptFilterRulesNftables adds accept rules to the ip filter table using nftables. -// This is used when iptables is not available. -func (r *router) acceptFilterRulesNftables(table *nftables.Table) error { - intf := ifname(r.wgIface.Name()) - - forwardChain := &nftables.Chain{ - Name: chainNameForward, - Table: table, - Type: nftables.ChainTypeFilter, - Hooknum: nftables.ChainHookForward, - Priority: nftables.ChainPriorityFilter, - } - r.insertForwardAcceptRules(forwardChain, intf) - - inputChain := &nftables.Chain{ - Name: chainNameInput, - Table: table, - Type: nftables.ChainTypeFilter, - Hooknum: nftables.ChainHookInput, - Priority: nftables.ChainPriorityFilter, - } - r.insertInputAcceptRule(inputChain, intf) - - return r.conn.Flush() -} - -// acceptExternalChainsRules adds accept rules to external chains (non-netbird, non-iptables tables). -// It dynamically finds chains at call time to handle chains that may have been created after startup. -func (r *router) acceptExternalChainsRules() error { - chains := r.findExternalChains() - if len(chains) == 0 { - return nil - } - - intf := ifname(r.wgIface.Name()) - for _, chain := range chains { - r.applyExternalChainAccept(chain, intf) - } - - if err := r.conn.Flush(); err != nil { - return fmt.Errorf("flush external chain rules: %w", err) - } - return nil -} - -func (r *router) applyExternalChainAccept(chain *nftables.Chain, intf []byte) { - if chain.Hooknum == nil { - log.Debugf("skipping external chain %s/%s: hooknum is nil", chain.Table.Name, chain.Name) - return - } - - log.Debugf("adding accept rules to external %s chain: %s %s/%s", - hookName(chain.Hooknum), familyName(chain.Table.Family), chain.Table.Name, chain.Name) - - switch *chain.Hooknum { - case *nftables.ChainHookForward: - r.insertForwardAcceptRules(chain, intf) - case *nftables.ChainHookInput: - r.insertInputAcceptRule(chain, intf) - } -} - -func (r *router) insertForwardAcceptRules(chain *nftables.Chain, intf []byte) { - existing, err := r.existingNetbirdRulesInChain(chain) - if err != nil { - log.Warnf("skip forward accept rules in %s/%s: %v", chain.Table.Name, chain.Name, err) - return - } - r.insertForwardIifRule(chain, intf, existing) - r.insertForwardOifEstablishedRule(chain, intf, existing) -} - -func (r *router) insertForwardIifRule(chain *nftables.Chain, intf []byte, existing map[string]bool) { - if existing[userDataAcceptForwardRuleIif] { - return - } - r.conn.InsertRule(&nftables.Rule{ - Table: chain.Table, - Chain: chain, - Exprs: []expr.Any{ - &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: intf}, - &expr.Counter{}, - &expr.Verdict{Kind: expr.VerdictAccept}, - }, - UserData: []byte(userDataAcceptForwardRuleIif), - }) -} - -func (r *router) insertForwardOifEstablishedRule(chain *nftables.Chain, intf []byte, existing map[string]bool) { - if existing[userDataAcceptForwardRuleOif] { - return - } - exprs := []expr.Any{ - &expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1}, - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: intf}, - } - r.conn.InsertRule(&nftables.Rule{ - Table: chain.Table, - Chain: chain, - Exprs: append(exprs, getEstablishedExprs(2)...), - UserData: []byte(userDataAcceptForwardRuleOif), - }) -} - -func (r *router) insertInputAcceptRule(chain *nftables.Chain, intf []byte) { - existing, err := r.existingNetbirdRulesInChain(chain) - if err != nil { - log.Warnf("skip input accept rule in %s/%s: %v", chain.Table.Name, chain.Name, err) - return - } - if existing[userDataAcceptInputRule] { - return - } - r.conn.InsertRule(&nftables.Rule{ - Table: chain.Table, - Chain: chain, - Exprs: []expr.Any{ - &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: intf}, - &expr.Counter{}, - &expr.Verdict{Kind: expr.VerdictAccept}, - }, - UserData: []byte(userDataAcceptInputRule), - }) -} - -// existingNetbirdRulesInChain returns the set of netbird-owned UserData tags present in a chain; callers must bail on error since InsertRule is additive. -func (r *router) existingNetbirdRulesInChain(chain *nftables.Chain) (map[string]bool, error) { - rules, err := r.conn.GetRules(chain.Table, chain) - if err != nil { - return nil, fmt.Errorf("list rules: %w", err) - } - present := map[string]bool{} - for _, rule := range rules { - if !isNetbirdAcceptRuleTag(rule.UserData) { - continue - } - present[string(rule.UserData)] = true - } - return present, nil -} - -func isNetbirdAcceptRuleTag(userData []byte) bool { - switch string(userData) { - case userDataAcceptForwardRuleIif, - userDataAcceptForwardRuleOif, - userDataAcceptInputRule: - return true - } - return false -} - -func (r *router) removeAcceptFilterRules() error { - var merr *multierror.Error - - if err := r.removeFilterTableRules(); err != nil { - merr = multierror.Append(merr, err) - } - - if err := r.removeExternalChainsRules(); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove external chain rules: %w", err)) - } - - return nberrors.FormatErrorOrNil(merr) -} - -func (r *router) removeFilterTableRules() error { - if r.filterTable == nil { - return nil - } - - ipt, err := iptables.NewWithProtocol(r.iptablesProto()) - if err != nil { - log.Debugf("iptables not available, using nftables to remove filter rules: %v", err) - return r.removeAcceptRulesFromTable(r.filterTable) - } - - if err := r.removeAcceptFilterRulesIptables(ipt); err != nil { - log.Debugf("iptables removal failed (table may be incompatible), falling back to nftables: %v", err) - return r.removeAcceptRulesFromTable(r.filterTable) - } - return nil -} - -func (r *router) removeAcceptRulesFromTable(table *nftables.Table) error { - chains, err := r.conn.ListChainsOfTableFamily(table.Family) - if err != nil { - return fmt.Errorf("list chains: %v", err) - } - - for _, chain := range chains { - if chain.Table.Name != table.Name { - continue - } - - if chain.Name != chainNameForward && chain.Name != chainNameInput { - continue - } - - if err := r.removeAcceptRulesFromChain(table, chain); err != nil { - return err - } - } - - return r.conn.Flush() -} - -func (r *router) removeAcceptRulesFromChain(table *nftables.Table, chain *nftables.Chain) error { - rules, err := r.conn.GetRules(table, chain) - if err != nil { - return fmt.Errorf("get rules from %s/%s: %v", table.Name, chain.Name, err) - } - - for _, rule := range rules { - if bytes.Equal(rule.UserData, []byte(userDataAcceptForwardRuleIif)) || - bytes.Equal(rule.UserData, []byte(userDataAcceptForwardRuleOif)) || - bytes.Equal(rule.UserData, []byte(userDataAcceptInputRule)) { - if err := r.conn.DelRule(rule); err != nil { - return fmt.Errorf("delete rule from %s/%s: %v", table.Name, chain.Name, err) - } - } - } - return nil -} - -// removeExternalChainsRules removes our accept rules from all external chains. -// This is deterministic - it scans for chains at removal time rather than relying on saved state, -// ensuring cleanup works even after a crash or if chains changed. -func (r *router) removeExternalChainsRules() error { - chains := r.findExternalChains() - if len(chains) == 0 { - return nil - } - - for _, chain := range chains { - if err := r.removeAcceptRulesFromChain(chain.Table, chain); err != nil { - log.Warnf("remove rules from external chain %s/%s: %v", chain.Table.Name, chain.Name, err) - } - } - - return r.conn.Flush() -} - -// findExternalChains scans for chains from non-netbird tables that have FORWARD or INPUT hooks. -// This is used both at startup (to know where to add rules) and at cleanup (to ensure deterministic removal). -func (r *router) findExternalChains() []*nftables.Chain { - var chains []*nftables.Chain - - families := []nftables.TableFamily{r.af.tableFamily, nftables.TableFamilyINet} - - for _, family := range families { - allChains, err := r.conn.ListChainsOfTableFamily(family) - if err != nil { - log.Debugf("list chains for family %d: %v", family, err) - continue - } - - for _, chain := range allChains { - if r.isExternalChain(chain) { - chains = append(chains, chain) - } - } - } - - return chains -} - -func (r *router) isExternalChain(chain *nftables.Chain) bool { - if r.workTable != nil && chain.Table.Name == r.workTable.Name { - return false - } - - // Skip firewalld-owned chains. Firewalld creates its chains with the - // NFT_CHAIN_OWNER flag, so inserting rules into them returns EPERM. - // We delegate acceptance to firewalld by trusting the interface instead. - if chain.Table.Name == firewalldTableName { - return false - } - - // Skip iptables/ip6tables-managed tables (adding nft-native rules breaks iptables-save compat) - if (chain.Table.Family == nftables.TableFamilyIPv4 || chain.Table.Family == nftables.TableFamilyIPv6) && isIptablesTable(chain.Table.Name) { - return false - } - - if chain.Type != nftables.ChainTypeFilter { - return false - } - - if chain.Hooknum == nil { - return false - } - - return *chain.Hooknum == *nftables.ChainHookForward || *chain.Hooknum == *nftables.ChainHookInput -} - -func isIptablesTable(name string) bool { - switch name { - case tableNameFilter, tableNat, tableMangle, tableRaw, tableSecurity: - return true - } - return false -} - -func (r *router) removeAcceptFilterRulesIptables(ipt *iptables.IPTables) error { - var merr *multierror.Error - - for _, rule := range r.getAcceptForwardRules() { - if err := ipt.DeleteIfExists("filter", chainNameForward, rule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove iptables forward rule: %v", err)) - } - } - - inputRule := r.getAcceptInputRule() - if err := ipt.DeleteIfExists("filter", chainNameInput, inputRule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove iptables input rule: %v", err)) - } - - return nberrors.FormatErrorOrNil(merr) -} - -// RemoveNatRule removes the prerouting mark rule -func (r *router) RemoveNatRule(pair firewall.RouterPair) error { - if err := r.refreshRulesMap(); err != nil { - return fmt.Errorf(refreshRulesMapError, err) - } - - var merr *multierror.Error - - if pair.Masquerade { - if err := r.removeNatRule(pair); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove prerouting rule: %w", err)) - } - - if err := r.removeNatRule(firewall.GetInversePair(pair)); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove inverse prerouting rule: %w", err)) - } - } - - if err := r.removeLegacyRouteRule(pair); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove legacy routing rule: %w", err)) - } - - // Set counters are decremented in the sub-methods above before flush. If flush fails, - // counters will be off until the next successful removal or refresh cycle. - if err := r.conn.Flush(); err != nil { - merr = multierror.Append(merr, fmt.Errorf("flush remove nat rules %s: %w", pair.Destination, err)) - } - - return nberrors.FormatErrorOrNil(merr) -} - -func (r *router) removeNatRule(pair firewall.RouterPair) error { - ruleKey := firewall.GenKey(firewall.PreroutingFormat, pair) - - rule, exists := r.rules[ruleKey] - if !exists { - log.Debugf("prerouting rule %s not found", ruleKey) - return nil - } - - if rule.Handle == 0 { - log.Warnf("prerouting rule %s has no handle, removing stale entry", ruleKey) - if err := r.decrementSetCounter(rule); err != nil { - log.Warnf("decrement set counter for stale rule %s: %v", ruleKey, err) - } - delete(r.rules, ruleKey) - return nil - } - - if err := r.conn.DelRule(rule); err != nil { - return fmt.Errorf("remove prerouting rule %s -> %s: %w", pair.Source, pair.Destination, err) - } - - log.Debugf("removed prerouting rule %s -> %s", pair.Source, pair.Destination) - - delete(r.rules, ruleKey) - - if err := r.decrementSetCounter(rule); err != nil { - return fmt.Errorf("decrement set counter: %w", err) - } - - return nil -} - -// refreshRulesMap rebuilds the rule map from the kernel. This removes stale entries -// (e.g. from failed flushes) and updates handles for all existing rules. -func (r *router) refreshRulesMap() error { - var merr *multierror.Error - newRules := make(map[string]*nftables.Rule) - for _, chain := range r.chains { - rules, err := r.conn.GetRules(chain.Table, chain) - if err != nil { - merr = multierror.Append(merr, fmt.Errorf("list rules for chain %s: %w", chain.Name, err)) - // preserve existing entries for this chain since we can't verify their state - for k, v := range r.rules { - if v.Chain != nil && v.Chain.Name == chain.Name { - newRules[k] = v - } - } - continue - } - for _, rule := range rules { - if len(rule.UserData) > 0 { - newRules[string(rule.UserData)] = rule - } - } - } - r.rules = newRules - return nberrors.FormatErrorOrNil(merr) -} - -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 - } - - protoNum, err := r.af.protoNum(rule.Protocol) - if err != nil { - return nil, fmt.Errorf("convert protocol to number: %w", err) - } - - if err := r.addDnatRedirect(rule, protoNum, ruleKey); err != nil { - return nil, err - } - - r.addDnatMasq(rule, protoNum, ruleKey) - - // Unlike iptables, there's no point in adding "out" rules in the forward chain here as our policy is ACCEPT. - // To overcome DROP policies in other chains, we'd have to add rules to the chains there. - // We also cannot just add "oif accept" there and filter in our own table as we don't know what is supposed to be allowed. - // TODO: find chains with drop policies and add rules there - - if err := r.conn.Flush(); err != nil { - return nil, fmt.Errorf("flush rules: %w", err) - } - - return &rule, nil -} - -func (r *router) addDnatRedirect(rule firewall.ForwardRule, protoNum uint8, ruleKey string) error { - dnatExprs := []expr.Any{ - &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpNeq, - Register: 1, - Data: ifname(r.wgIface.Name()), - }, - &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: []byte{protoNum}, - }, - &expr.Payload{ - DestRegister: 1, - Base: expr.PayloadBaseTransportHeader, - Offset: 2, - Len: 2, - }, - } - dnatExprs = append(dnatExprs, applyPort(&rule.DestinationPort, false)...) - - // shifted translated port is not supported in nftables, so we hand this over to xtables - if rule.TranslatedPort.IsRange && len(rule.TranslatedPort.Values) == 2 { - if rule.TranslatedPort.Values[0] != rule.DestinationPort.Values[0] || - rule.TranslatedPort.Values[1] != rule.DestinationPort.Values[1] { - return r.addXTablesRedirect(dnatExprs, ruleKey, rule) - } - } - - additionalExprs, regProtoMin, regProtoMax, err := r.handleTranslatedPort(rule) - if err != nil { - return err - } - dnatExprs = append(dnatExprs, additionalExprs...) - - dnatExprs = append(dnatExprs, - &expr.NAT{ - Type: expr.NATTypeDestNAT, - Family: uint32(r.af.tableFamily), - RegAddrMin: 1, - RegProtoMin: regProtoMin, - RegProtoMax: regProtoMax, - }, - ) - - dnatRule := &nftables.Rule{ - Table: r.workTable, - Chain: r.chains[chainNameRoutingRdr], - Exprs: dnatExprs, - UserData: []byte(ruleKey + dnatSuffix), - } - r.conn.AddRule(dnatRule) - r.rules[ruleKey+dnatSuffix] = dnatRule - - return nil -} - -func (r *router) handleTranslatedPort(rule firewall.ForwardRule) ([]expr.Any, uint32, uint32, error) { - switch { - case rule.TranslatedPort.IsRange && len(rule.TranslatedPort.Values) == 2: - return r.handlePortRange(rule) - case len(rule.TranslatedPort.Values) == 0: - return r.handleAddressOnly(rule) - case len(rule.TranslatedPort.Values) == 1: - return r.handleSinglePort(rule) - default: - return nil, 0, 0, fmt.Errorf("invalid translated port: %v", rule.TranslatedPort) - } -} - -func (r *router) handlePortRange(rule firewall.ForwardRule) ([]expr.Any, uint32, uint32, error) { - exprs := []expr.Any{ - &expr.Immediate{ - Register: 1, - Data: rule.TranslatedAddress.AsSlice(), - }, - &expr.Immediate{ - Register: 2, - Data: binaryutil.BigEndian.PutUint16(rule.TranslatedPort.Values[0]), - }, - &expr.Immediate{ - Register: 3, - Data: binaryutil.BigEndian.PutUint16(rule.TranslatedPort.Values[1]), - }, - } - return exprs, 2, 3, nil -} - -func (r *router) handleAddressOnly(rule firewall.ForwardRule) ([]expr.Any, uint32, uint32, error) { - exprs := []expr.Any{ - &expr.Immediate{ - Register: 1, - Data: rule.TranslatedAddress.AsSlice(), - }, - } - return exprs, 0, 0, nil -} - -func (r *router) handleSinglePort(rule firewall.ForwardRule) ([]expr.Any, uint32, uint32, error) { - exprs := []expr.Any{ - &expr.Immediate{ - Register: 1, - Data: rule.TranslatedAddress.AsSlice(), - }, - &expr.Immediate{ - Register: 2, - Data: binaryutil.BigEndian.PutUint16(rule.TranslatedPort.Values[0]), - }, - } - return exprs, 2, 0, nil -} - -func (r *router) addXTablesRedirect(dnatExprs []expr.Any, ruleKey string, rule firewall.ForwardRule) error { - dnatExprs = append(dnatExprs, - &expr.Counter{}, - &expr.Target{ - Name: "DNAT", - Rev: 2, - Info: &xt.NatRange2{ - NatRange: xt.NatRange{ - Flags: uint(xt.NatRangeMapIPs | xt.NatRangeProtoSpecified | xt.NatRangeProtoOffset), - MinIP: rule.TranslatedAddress.AsSlice(), - MaxIP: rule.TranslatedAddress.AsSlice(), - MinPort: rule.TranslatedPort.Values[0], - MaxPort: rule.TranslatedPort.Values[1], - }, - BasePort: rule.DestinationPort.Values[0], - }, - }, - ) - - natTable := &nftables.Table{ - Name: tableNat, - Family: r.af.tableFamily, - } - dnatRule := &nftables.Rule{ - Table: natTable, - Chain: &nftables.Chain{ - Name: chainNameNatPrerouting, - Table: natTable, - Type: nftables.ChainTypeNAT, - Hooknum: nftables.ChainHookPrerouting, - Priority: nftables.ChainPriorityNATDest, - }, - Exprs: dnatExprs, - UserData: []byte(ruleKey + dnatSuffix), - } - r.conn.AddRule(dnatRule) - r.rules[ruleKey+dnatSuffix] = dnatRule - - return nil -} - -func (r *router) addDnatMasq(rule firewall.ForwardRule, protoNum uint8, ruleKey string) { - masqExprs := []expr.Any{ - &expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: ifname(r.wgIface.Name()), - }, - &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: []byte{protoNum}, - }, - &expr.Payload{ - DestRegister: 1, - Base: expr.PayloadBaseNetworkHeader, - Offset: r.af.dstAddrOffset, - Len: r.af.addrLen, - }, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: rule.TranslatedAddress.AsSlice(), - }, - } - - masqExprs = append(masqExprs, applyPort(&rule.TranslatedPort, false)...) - masqExprs = append(masqExprs, &expr.Masq{}) - - masqRule := &nftables.Rule{ - Table: r.workTable, - Chain: r.chains[chainNameRoutingNat], - Exprs: masqExprs, - UserData: []byte(ruleKey + snatSuffix), - } - r.conn.AddRule(masqRule) - r.rules[ruleKey+snatSuffix] = masqRule -} - -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) - } - - var merr *multierror.Error - var needsFlush bool - - if dnatRule, exists := r.rules[ruleKey+dnatSuffix]; exists { - if dnatRule.Handle == 0 { - log.Warnf("dnat rule %s has no handle, removing stale entry", ruleKey+dnatSuffix) - delete(r.rules, ruleKey+dnatSuffix) - } else if err := r.conn.DelRule(dnatRule); err != nil { - merr = multierror.Append(merr, fmt.Errorf("delete dnat rule: %w", err)) - } else { - needsFlush = true - } - } - - if masqRule, exists := r.rules[ruleKey+snatSuffix]; exists { - if masqRule.Handle == 0 { - log.Warnf("snat rule %s has no handle, removing stale entry", ruleKey+snatSuffix) - delete(r.rules, ruleKey+snatSuffix) - } else if err := r.conn.DelRule(masqRule); err != nil { - merr = multierror.Append(merr, fmt.Errorf("delete snat rule: %w", err)) - } else { - needsFlush = true - } - } - - if needsFlush { - if err := r.conn.Flush(); err != nil { - merr = multierror.Append(merr, fmt.Errorf(flushError, err)) - } - } - - if merr == nil { - delete(r.rules, ruleKey+dnatSuffix) - delete(r.rules, ruleKey+snatSuffix) - } - - return nberrors.FormatErrorOrNil(merr) -} - -func (r *router) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error { - nfset, err := r.conn.GetSetByName(r.workTable, set.HashedName()) - if err != nil { - return fmt.Errorf("get set %s: %w", set.HashedName(), err) - } - - elements := r.convertPrefixesToSet(prefixes) - if err := r.conn.SetAddElements(nfset, elements); err != nil { - return fmt.Errorf("add elements to set %s: %w", set.HashedName(), err) - } - - if err := r.conn.Flush(); err != nil { - return fmt.Errorf(flushError, err) - } - - log.Debugf("updated set %s with prefixes %v", set.HashedName(), prefixes) - - return nil -} - -// AddInboundDNAT adds an inbound DNAT rule redirecting traffic from NetBird peers to local services. -func (r *router) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { - ruleID := fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort) - - if _, exists := r.rules[ruleID]; exists { - return nil - } - - protoNum, err := r.af.protoNum(protocol) - if err != nil { - return fmt.Errorf("convert protocol to number: %w", err) - } - - exprs := []expr.Any{ - &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: ifname(r.wgIface.Name()), - }, - &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 2}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 2, - Data: []byte{protoNum}, - }, - &expr.Payload{ - DestRegister: 3, - Base: expr.PayloadBaseTransportHeader, - Offset: 2, - Len: 2, - }, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 3, - Data: binaryutil.BigEndian.PutUint16(originalPort), - }, - } - - bits := 32 - if localAddr.Is6() { - bits = 128 - } - exprs = append(exprs, r.applyPrefix(netip.PrefixFrom(localAddr, bits), false)...) - - exprs = append(exprs, - &expr.Immediate{ - Register: 1, - Data: localAddr.AsSlice(), - }, - &expr.Immediate{ - Register: 2, - Data: binaryutil.BigEndian.PutUint16(translatedPort), - }, - &expr.NAT{ - Type: expr.NATTypeDestNAT, - Family: uint32(r.af.tableFamily), - RegAddrMin: 1, - RegProtoMin: 2, - RegProtoMax: 0, - }, - ) - - dnatRule := &nftables.Rule{ - Table: r.workTable, - Chain: r.chains[chainNameRoutingRdr], - Exprs: exprs, - UserData: []byte(ruleID), - } - r.conn.AddRule(dnatRule) - - if err := r.conn.Flush(); err != nil { - return fmt.Errorf("add inbound DNAT rule: %w", err) - } - - r.rules[ruleID] = dnatRule - - return nil -} - -// RemoveInboundDNAT removes an inbound DNAT rule. -func (r *router) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { - if err := r.refreshRulesMap(); err != nil { - return fmt.Errorf(refreshRulesMapError, err) - } - - ruleID := fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort) - - rule, exists := r.rules[ruleID] - if !exists { - return nil - } - - if rule.Handle == 0 { - log.Warnf("inbound DNAT rule %s has no handle, removing stale entry", ruleID) - delete(r.rules, ruleID) - return nil - } - - if err := r.conn.DelRule(rule); err != nil { - return fmt.Errorf("delete inbound DNAT rule %s: %w", ruleID, err) - } - if err := r.conn.Flush(); err != nil { - return fmt.Errorf("flush delete inbound DNAT rule: %w", err) - } - delete(r.rules, ruleID) - - return nil -} - -// ensureNATOutputChain lazily creates the OUTPUT NAT chain on first use. -func (r *router) ensureNATOutputChain() error { - if _, exists := r.chains[chainNameNATOutput]; exists { - return nil - } - - r.chains[chainNameNATOutput] = r.conn.AddChain(&nftables.Chain{ - Name: chainNameNATOutput, - Table: r.workTable, - Hooknum: nftables.ChainHookOutput, - Priority: nftables.ChainPriorityNATDest, - Type: nftables.ChainTypeNAT, - }) - - if err := r.conn.Flush(); err != nil { - delete(r.chains, chainNameNATOutput) - return fmt.Errorf("create NAT output chain: %w", err) - } - return nil -} - -// AddOutputDNAT adds an OUTPUT chain DNAT rule for locally-generated traffic. -func (r *router) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { - ruleID := fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort) - - if _, exists := r.rules[ruleID]; exists { - return nil - } - - if err := r.ensureNATOutputChain(); err != nil { - return err - } - - protoNum, err := r.af.protoNum(protocol) - if err != nil { - return fmt.Errorf("convert protocol to number: %w", err) - } - - exprs := []expr.Any{ - &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: []byte{protoNum}, - }, - &expr.Payload{ - DestRegister: 2, - Base: expr.PayloadBaseTransportHeader, - Offset: 2, - Len: 2, - }, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 2, - Data: binaryutil.BigEndian.PutUint16(originalPort), - }, - } - - bits := 32 - if localAddr.Is6() { - bits = 128 - } - exprs = append(exprs, r.applyPrefix(netip.PrefixFrom(localAddr, bits), false)...) - - exprs = append(exprs, - &expr.Immediate{ - Register: 1, - Data: localAddr.AsSlice(), - }, - &expr.Immediate{ - Register: 2, - Data: binaryutil.BigEndian.PutUint16(translatedPort), - }, - &expr.NAT{ - Type: expr.NATTypeDestNAT, - Family: uint32(r.af.tableFamily), - RegAddrMin: 1, - RegProtoMin: 2, - }, - ) - - dnatRule := &nftables.Rule{ - Table: r.workTable, - Chain: r.chains[chainNameNATOutput], - Exprs: exprs, - UserData: []byte(ruleID), - } - r.conn.AddRule(dnatRule) - - if err := r.conn.Flush(); err != nil { - return fmt.Errorf("add output DNAT rule: %w", err) - } - - r.rules[ruleID] = dnatRule - - return nil -} - -// RemoveOutputDNAT removes an OUTPUT chain DNAT rule. -func (r *router) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { - if err := r.refreshRulesMap(); err != nil { - return fmt.Errorf(refreshRulesMapError, err) - } - - ruleID := fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort) - - rule, exists := r.rules[ruleID] - if !exists { - return nil - } - - if rule.Handle == 0 { - log.Warnf("output DNAT rule %s has no handle, removing stale entry", ruleID) - delete(r.rules, ruleID) - return nil - } - - if err := r.conn.DelRule(rule); err != nil { - return fmt.Errorf("delete output DNAT rule %s: %w", ruleID, err) - } - if err := r.conn.Flush(); err != nil { - return fmt.Errorf("flush delete output DNAT rule: %w", err) - } - delete(r.rules, ruleID) - - return nil -} - -// applyNetwork generates nftables expressions for networks (CIDR) or sets -func (r *router) applyNetwork( - network firewall.Network, - setPrefixes []netip.Prefix, - isSource bool, -) ([]expr.Any, error) { - if network.IsSet() { - exprs, err := r.getIpSet(network.Set, setPrefixes, isSource) - if err != nil { - return nil, fmt.Errorf("source: %w", err) - } - return exprs, nil - } - - if network.IsPrefix() { - return r.applyPrefix(network.Prefix, isSource), nil - } - - return nil, nil -} - -// applyPrefix generates nftables expressions for a CIDR prefix -func (r *router) applyPrefix(prefix netip.Prefix, isSource bool) []expr.Any { - // dst offset by default - offset := r.af.dstAddrOffset - if isSource { - // src offset - offset = r.af.srcAddrOffset - } - - ones := prefix.Bits() - // unspecified address (/0) doesn't need extra expressions - if ones == 0 { - return nil - } - - mask := net.CIDRMask(ones, r.af.totalBits) - xor := make([]byte, r.af.addrLen) - - return []expr.Any{ - &expr.Payload{ - DestRegister: 1, - Base: expr.PayloadBaseNetworkHeader, - Offset: offset, - Len: r.af.addrLen, - }, - &expr.Bitwise{ - DestRegister: 1, - SourceRegister: 1, - Len: r.af.addrLen, - Mask: mask, - Xor: xor, - }, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: prefix.Masked().Addr().AsSlice(), - }, - } -} - -func applyPort(port *firewall.Port, isSource bool) []expr.Any { - if port == nil { - return nil - } - - var exprs []expr.Any - - offset := uint32(2) // Default offset for destination port - if isSource { - offset = 0 // Offset for source port - } - - exprs = append(exprs, &expr.Payload{ - DestRegister: 1, - Base: expr.PayloadBaseTransportHeader, - Offset: offset, - Len: 2, - }) - - if port.IsRange && len(port.Values) == 2 { - // Handle port range - exprs = append(exprs, - &expr.Range{ - Op: expr.CmpOpEq, - Register: 1, - FromData: binaryutil.BigEndian.PutUint16(port.Values[0]), - ToData: binaryutil.BigEndian.PutUint16(port.Values[1]), - }, - ) - } else { - // Handle single port or multiple ports - for i, p := range port.Values { - if i > 0 { - // Add a bitwise OR operation between port checks - exprs = append(exprs, &expr.Bitwise{ - SourceRegister: 1, - DestRegister: 1, - Len: 4, - Mask: []byte{0x00, 0x00, 0xff, 0xff}, - Xor: []byte{0x00, 0x00, 0x00, 0x00}, - }) - } - exprs = append(exprs, &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: binaryutil.BigEndian.PutUint16(p), - }) - } - } - - return exprs -} - -func getCtNewExprs() []expr.Any { - return []expr.Any{ - &expr.Ct{ - Key: expr.CtKeySTATE, - Register: 1, - }, - &expr.Bitwise{ - SourceRegister: 1, - DestRegister: 1, - Len: 4, - Mask: binaryutil.NativeEndian.PutUint32(expr.CtStateBitNEW), - Xor: binaryutil.NativeEndian.PutUint32(0), - }, - &expr.Cmp{ - Op: expr.CmpOpNeq, - Register: 1, - Data: []byte{0, 0, 0, 0}, - }, - } -} - -func (r *router) getIpSetExprs(ref refcounter.Ref[*nftables.Set], isSource bool) ([]expr.Any, error) { - // dst offset by default - offset := r.af.dstAddrOffset - if isSource { - // src offset - offset = r.af.srcAddrOffset - } - - return []expr.Any{ - &expr.Payload{ - DestRegister: 1, - Base: expr.PayloadBaseNetworkHeader, - Offset: offset, - Len: r.af.addrLen, - }, - &expr.Lookup{ - SourceRegister: 1, - SetName: ref.Out.Name, - SetID: ref.Out.ID, - }, - }, nil -} diff --git a/client/firewall/nftables/router_linux_test.go b/client/firewall/nftables/router_linux_test.go index 2fc664d51..49dbfc8f2 100644 --- a/client/firewall/nftables/router_linux_test.go +++ b/client/firewall/nftables/router_linux_test.go @@ -37,7 +37,7 @@ func TestNftablesManager_AddNatRule(t *testing.T) { for _, testCase := range test.InsertRuleTestCases { t.Run(testCase.Name, func(t *testing.T) { - // need fw manager to init both acl mgr and router for all chains to be present + // need fw manager to init both acl mgr and family for all chains to be present manager, err := Create(ifaceMock, iface.DefaultMTU) t.Cleanup(func() { require.NoError(t, manager.Close(nil)) @@ -47,7 +47,7 @@ func TestNftablesManager_AddNatRule(t *testing.T) { nftablesTestingClient := &nftables.Conn{} - rtr := manager.router + rtr := manager.family4 err = rtr.AddNatRule(testCase.InputPair) require.NoError(t, err, "pair should be inserted") @@ -90,9 +90,9 @@ func TestNftablesManager_AddNatRule(t *testing.T) { } // Build CIDR matching expressions - testRouter := &router{af: afIPv4} - sourceExp := testRouter.applyPrefix(testCase.InputPair.Source.Prefix, true) - destExp := testRouter.applyPrefix(testCase.InputPair.Destination.Prefix, false) + testRouter := &family{af: afIPv4} + sourceExp := prefixMatchExprs(testRouter.af, testCase.InputPair.Source.Prefix, true) + destExp := prefixMatchExprs(testRouter.af, testCase.InputPair.Destination.Prefix, false) // Combine all expressions in the correct order // nolint:gocritic @@ -100,14 +100,14 @@ func TestNftablesManager_AddNatRule(t *testing.T) { testingExpression = append(testingExpression, sourceExp...) testingExpression = append(testingExpression, destExp...) - natRuleKey := firewall.GenKey(firewall.PreroutingFormat, testCase.InputPair) + natRuleKey := testCase.InputPair.GenKey(firewall.PreroutingFormat) found := 0 for _, chain := range rtr.chains { if chain.Name == chainNameManglePrerouting { rules, err := nftablesTestingClient.GetRules(chain.Table, chain) require.NoError(t, err, "should list rules for %s table and %s chain", chain.Table.Name, chain.Name) for _, rule := range rules { - if len(rule.UserData) > 0 && string(rule.UserData) == natRuleKey { + if len(rule.UserData) > 0 && firewall.RuleID(rule.UserData) == natRuleKey { // Compare expressions up to the mark setting expressions require.ElementsMatchf(t, rule.Exprs[:len(testingExpression)], testingExpression, "prerouting nat rule elements should match") found = 1 @@ -135,19 +135,19 @@ func TestNftablesManager_RemoveNatRule(t *testing.T) { require.NoError(t, err) require.NoError(t, manager.Init(nil)) - rtr := manager.router + rtr := manager.family4 - // First add the NAT rule using the router's method + // First add the NAT rule using the family's method err = rtr.AddNatRule(testCase.InputPair) require.NoError(t, err, "should add NAT rule") // Verify the rule was added - natRuleKey := firewall.GenKey(firewall.PreroutingFormat, testCase.InputPair) + natRuleKey := testCase.InputPair.GenKey(firewall.PreroutingFormat) found := false rules, err := rtr.conn.GetRules(rtr.workTable, rtr.chains[chainNameManglePrerouting]) require.NoError(t, err, "should list rules") for _, rule := range rules { - if len(rule.UserData) > 0 && string(rule.UserData) == natRuleKey { + if len(rule.UserData) > 0 && firewall.RuleID(rule.UserData) == natRuleKey { found = true break } @@ -163,7 +163,7 @@ func TestNftablesManager_RemoveNatRule(t *testing.T) { rules, err = rtr.conn.GetRules(rtr.workTable, rtr.chains[chainNameManglePrerouting]) require.NoError(t, err, "should list rules after removal") for _, rule := range rules { - if len(rule.UserData) > 0 && string(rule.UserData) == natRuleKey { + if len(rule.UserData) > 0 && firewall.RuleID(rule.UserData) == natRuleKey { found = true break } @@ -200,11 +200,10 @@ func TestRouter_AddRouteFiltering(t *testing.T) { defer deleteWorkTable() - r, err := newRouter(workTable, ifaceMock, iface.DefaultMTU) - require.NoError(t, err, "Failed to create router") + r := newFamily(workTable, ifaceMock, iface.DefaultMTU) require.NoError(t, r.init(workTable)) - defer func(r *router) { + defer func(r *family) { require.NoError(t, r.Reset(), "Failed to reset rules") }(r) @@ -314,16 +313,16 @@ func TestRouter_AddRouteFiltering(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ruleKey, err := r.AddRouteFiltering(nil, tt.sources, firewall.Network{Prefix: tt.destination}, tt.proto, tt.sPort, tt.dPort, tt.action) - require.NoError(t, err, "AddRouteFiltering failed") + ruleKey, err := r.AddFilterRule(nil, tt.sources, firewall.Network{Prefix: tt.destination}, tt.proto, tt.sPort, tt.dPort, tt.action) + require.NoError(t, err, "AddFilterRule failed") t.Cleanup(func() { - require.NoError(t, r.DeleteRouteRule(ruleKey), "Failed to delete rule") + require.NoError(t, r.DeleteFilterRule(ruleKey), "Failed to delete rule") }) - // Check if the rule is in the internal map - rule, ok := r.rules[ruleKey.ID()] - assert.True(t, ok, "Rule not found in internal map") + stored, ok := r.filters[id.RuleID(ruleKey.ID())] + require.True(t, ok, "Rule not found in filters map") + rule := stored.nftRule t.Log("Internal rule expressions:") for i, expr := range rule.Exprs { @@ -339,7 +338,7 @@ func TestRouter_AddRouteFiltering(t *testing.T) { var nftRule *nftables.Rule for _, rule := range rules { - if string(rule.UserData) == ruleKey.ID() { + if firewall.RuleID(rule.UserData) == ruleKey.ID() { nftRule = rule break } @@ -367,12 +366,11 @@ func TestNftablesCreateIpSet(t *testing.T) { defer deleteWorkTable() - r, err := newRouter(workTable, ifaceMock, iface.DefaultMTU) - require.NoError(t, err, "Failed to create router") + r := newFamily(workTable, ifaceMock, iface.DefaultMTU) require.NoError(t, r.init(workTable)) defer func() { - require.NoError(t, r.Reset(), "Failed to reset router") + require.NoError(t, r.Reset(), "Failed to reset family") }() tests := []struct { @@ -509,6 +507,58 @@ func TestNftablesCreateIpSet(t *testing.T) { } } +// TestNftablesUpdateSetMergesOverlapping verifies that UpdateSet merges +// overlapping prefixes before adding them. An interval set rejects +// overlapping elements, so without the merge a batch holding a /32 already +// covered by a /24, or a duplicate address as DNS resolution can produce, +// would fail. +func TestNftablesUpdateSetMergesOverlapping(t *testing.T) { + if check() != NFTABLES { + t.Skip("nftables not supported on this system") + } + + workTable, err := createWorkTable() + require.NoError(t, err, "create work table") + defer deleteWorkTable() + + r := newFamily(workTable, ifaceMock, iface.DefaultMTU) + require.NoError(t, r.init(workTable)) + defer func() { + require.NoError(t, r.Reset(), "reset family") + }() + + initial := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24")} + set := firewall.NewPrefixSet(initial) + + created, err := r.createIpSet(set.HashedName(), setInput{prefixes: initial}) + require.NoError(t, err, "create ip set") + require.NotNil(t, created) + + overlapping := []netip.Prefix{ + netip.MustParsePrefix("192.168.1.0/24"), + netip.MustParsePrefix("192.168.1.1/32"), + netip.MustParsePrefix("192.168.1.1/32"), + } + require.NoError(t, r.UpdateSet(set, overlapping), "UpdateSet must merge overlapping prefixes") + + fetchedSet, err := r.conn.GetSetByName(r.workTable, set.HashedName()) + require.NoError(t, err, "fetch updated set") + elements, err := r.conn.GetSetElements(fetchedSet) + require.NoError(t, err, "get set elements") + + starts := make(map[string]bool) + for _, elem := range elements { + if elem.IntervalEnd { + continue + } + starts[netip.AddrFrom4(*(*[4]byte)(elem.Key)).String()] = true + } + // The /32s are covered by the /24, so the update adds one interval and + // leaves the one created earlier in place. + assert.Equal(t, map[string]bool{"10.0.0.0": true, "192.168.1.0": true}, starts, + "merged set must hold the original and the merged interval") +} + func TestNftablesCreateIpSet_IPv6(t *testing.T) { if check() != NFTABLES { t.Skip("nftables not supported on this system") @@ -518,11 +568,10 @@ func TestNftablesCreateIpSet_IPv6(t *testing.T) { require.NoError(t, err, "Failed to create v6 work table") defer deleteWorkTableIPv6() - r, err := newRouter(workTable, ifaceMock, iface.DefaultMTU) - require.NoError(t, err, "Failed to create router") + r := newFamily(workTable, ifaceMock, iface.DefaultMTU) require.NoError(t, r.init(workTable)) defer func() { - require.NoError(t, r.Reset(), "Failed to reset router") + require.NoError(t, r.Reset(), "Failed to reset family") }() tests := []struct { @@ -748,6 +797,14 @@ func containsPort(exprs []expr.Any, port *firewall.Port, isSource bool) bool { } } } + case *expr.Lookup: + // Multiple discrete ports compile to an anonymous set lookup + // rather than a chain of comparisons. The set's id and name are + // assigned dynamically, so matching the lookup is enough here; + // the set elements are verified separately. + if !port.IsRange && len(port.Values) > 1 { + portMatchFound = true + } } if payloadFound && portMatchFound { return true @@ -861,13 +918,12 @@ func TestRouter_RefreshRulesMap_RemovesStaleEntries(t *testing.T) { require.NoError(t, err) defer deleteWorkTable() - r, err := newRouter(workTable, ifaceMock, iface.DefaultMTU) - require.NoError(t, err) + r := newFamily(workTable, ifaceMock, iface.DefaultMTU) require.NoError(t, r.init(workTable)) defer func() { require.NoError(t, r.Reset()) }() // Add a real rule to the kernel - ruleKey, err := r.AddRouteFiltering( + ruleKey, err := r.AddFilterRule( nil, []netip.Prefix{netip.MustParsePrefix("192.168.1.0/24")}, firewall.Network{Prefix: netip.MustParsePrefix("10.0.0.0/24")}, @@ -878,11 +934,11 @@ func TestRouter_RefreshRulesMap_RemovesStaleEntries(t *testing.T) { ) require.NoError(t, err) t.Cleanup(func() { - require.NoError(t, r.DeleteRouteRule(ruleKey)) + require.NoError(t, r.DeleteFilterRule(ruleKey)) }) // Inject a stale entry with Handle=0 (simulates store-before-flush failure) - staleKey := "stale-rule-that-does-not-exist" + staleKey := firewall.RuleID("stale-rule-that-does-not-exist") r.rules[staleKey] = &nftables.Rule{ Table: r.workTable, Chain: r.chains[chainNameRoutingFw], @@ -902,6 +958,54 @@ func TestRouter_RefreshRulesMap_RemovesStaleEntries(t *testing.T) { assert.NotZero(t, realRule.Handle, "real rule should have a valid handle") } +// TestRouter_DeleteRouteRule_RemovesKernelRule verifies a route filter +// rule is actually removed from the kernel on delete. The route chain is +// not refreshed by Flush, so the stored rule carries a zero handle; +// DeleteFilterRule must pull live handles itself before issuing the +// delete or the kernel rule leaks. Regression test for that path. +func TestRouter_DeleteRouteRule_RemovesKernelRule(t *testing.T) { + if check() != NFTABLES { + t.Skip("nftables not supported on this system") + } + + workTable, err := createWorkTable() + require.NoError(t, err) + defer deleteWorkTable() + + r := newFamily(workTable, ifaceMock, iface.DefaultMTU) + require.NoError(t, r.init(workTable)) + defer func() { require.NoError(t, r.Reset()) }() + + ruleKey, err := r.AddFilterRule( + nil, + []netip.Prefix{netip.MustParsePrefix("192.168.1.0/24")}, + firewall.Network{Prefix: netip.MustParsePrefix("10.0.0.0/24")}, + firewall.ProtocolTCP, + nil, + &firewall.Port{Values: []uint16{80}}, + firewall.ActionAccept, + ) + require.NoError(t, err) + + countKernelRules := func() int { + list, err := r.conn.GetRules(r.workTable, r.chains[chainNameRoutingFw]) + require.NoError(t, err) + n := 0 + for _, rule := range list { + if string(rule.UserData) == string(ruleKey.ID()) { + n++ + } + } + return n + } + + require.Equal(t, 1, countKernelRules(), "rule should be present in the kernel after add") + + require.NoError(t, r.DeleteFilterRule(ruleKey)) + assert.Equal(t, 0, countKernelRules(), "rule must be removed from the kernel after delete") + assert.NotContains(t, r.filters, ruleKey.ID(), "filters map entry should be cleared") +} + func TestRouter_DeleteRouteRule_StaleHandle(t *testing.T) { if check() != NFTABLES { t.Skip("nftables not supported on this system") @@ -911,24 +1015,27 @@ func TestRouter_DeleteRouteRule_StaleHandle(t *testing.T) { require.NoError(t, err) defer deleteWorkTable() - r, err := newRouter(workTable, ifaceMock, iface.DefaultMTU) - require.NoError(t, err) + r := newFamily(workTable, ifaceMock, iface.DefaultMTU) require.NoError(t, r.init(workTable)) defer func() { require.NoError(t, r.Reset()) }() // Inject a stale entry with Handle=0 - staleKey := "stale-route-rule" - r.rules[staleKey] = &nftables.Rule{ - Table: r.workTable, - Chain: r.chains[chainNameRoutingFw], - Handle: 0, - UserData: []byte(staleKey), + staleKey := id.RuleID("stale-route-rule") + staleRule := &Rule{ + nftRule: &nftables.Rule{ + Table: r.workTable, + Chain: r.chains[chainNameRoutingFw], + Handle: 0, + UserData: []byte(staleKey), + }, + id: staleKey, } + r.filters[staleKey] = staleRule - // DeleteRouteRule should not return an error for stale handles - err = r.DeleteRouteRule(id.RuleID(staleKey)) + // DeleteFilterRule should not return an error for stale handles + err = r.DeleteFilterRule(staleRule) assert.NoError(t, err, "deleting a stale rule should not error") - assert.NotContains(t, r.rules, staleKey, "stale entry should be cleaned up") + assert.NotContains(t, r.filters, staleKey, "stale entry should be cleaned up") } func TestRouter_AddNatRule_WithStaleEntry(t *testing.T) { @@ -950,7 +1057,7 @@ func TestRouter_AddNatRule_WithStaleEntry(t *testing.T) { Masquerade: true, } - rtr := manager.router + rtr := manager.family4 // First add succeeds err = rtr.AddNatRule(pair) @@ -960,11 +1067,11 @@ func TestRouter_AddNatRule_WithStaleEntry(t *testing.T) { }) // Corrupt the handle to simulate stale state - natRuleKey := firewall.GenKey(firewall.PreroutingFormat, pair) + natRuleKey := pair.GenKey(firewall.PreroutingFormat) if rule, exists := rtr.rules[natRuleKey]; exists { rule.Handle = 0 } - inverseKey := firewall.GenKey(firewall.PreroutingFormat, firewall.GetInversePair(pair)) + inverseKey := firewall.GetInversePair(pair).GenKey(firewall.PreroutingFormat) if rule, exists := rtr.rules[inverseKey]; exists { rule.Handle = 0 } @@ -979,7 +1086,7 @@ func TestRouter_AddNatRule_WithStaleEntry(t *testing.T) { found := 0 for _, rule := range rules { - if len(rule.UserData) > 0 && string(rule.UserData) == natRuleKey { + if len(rule.UserData) > 0 && firewall.RuleID(rule.UserData) == natRuleKey { found++ } } @@ -1010,7 +1117,7 @@ func TestCalculateLastIP(t *testing.T) { } func TestConvertPrefixesToSet_IPv6(t *testing.T) { - r := &router{af: afIPv6} + r := &family{af: afIPv6} prefixes := []netip.Prefix{ netip.MustParsePrefix("fd00::/64"), netip.MustParsePrefix("2001:db8::1/128"), diff --git a/client/firewall/nftables/routing_linux.go b/client/firewall/nftables/routing_linux.go new file mode 100644 index 000000000..4115c94bd --- /dev/null +++ b/client/firewall/nftables/routing_linux.go @@ -0,0 +1,558 @@ +//go:build !android + +package nftables + +import ( + "fmt" + "strings" + + "github.com/google/nftables" + "github.com/google/nftables/binaryutil" + "github.com/google/nftables/expr" + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + "golang.org/x/sys/unix" + + nberrors "github.com/netbirdio/netbird/client/errors" + firewall "github.com/netbirdio/netbird/client/firewall/manager" + nbnet "github.com/netbirdio/netbird/client/net" +) + +func (r *family) AddNatRule(pair firewall.RouterPair) error { + if err := r.refreshRulesMap(); err != nil { + return fmt.Errorf(refreshRulesMapError, err) + } + + // Resolve every rule's match expressions before queueing any of them: a + // message buffered on the shared connection cannot be un-queued, so + // returning an error after queueing would leave the next caller's Flush + // to commit a rule nothing tracks. + var legacyExprs []expr.Any + if r.legacyManagement { + log.Warnf("This peer is connected to a NetBird Management service with an older version. Allowing all traffic for %s", pair.Destination) + + var err error + legacyExprs, err = r.legacyRouteRuleExprs(pair) + if err != nil { + return fmt.Errorf("build legacy routing rule: %w", err) + } + } + + inverse := firewall.GetInversePair(pair) + var natExprs, inverseExprs []expr.Any + if pair.Masquerade { + var err error + natExprs, err = r.natRuleExprs(pair) + if err != nil { + r.dropNetworkMatch(legacyExprs) + return fmt.Errorf("build nat rule: %w", err) + } + + inverseExprs, err = r.natRuleExprs(inverse) + if err != nil { + r.dropNetworkMatch(legacyExprs) + r.dropNetworkMatch(natExprs) + return fmt.Errorf("build inverse nat rule: %w", err) + } + } + + if legacyExprs != nil { + r.queueLegacyRouteRule(pair, legacyExprs) + } + if pair.Masquerade { + r.queueNatRule(pair, natExprs) + r.queueNatRule(inverse, inverseExprs) + } + + if err := r.conn.Flush(); err != nil { + r.rollbackRules(pair) + return fmt.Errorf("insert rules for %s: %w", pair.Destination, err) + } + + return nil +} + +// rollbackRules cleans up unflushed rules and their set counters after a flush failure. +func (r *family) rollbackRules(pair firewall.RouterPair) { + keys := []firewall.RuleID{ + pair.GenKey(firewall.ForwardingFormat), + pair.GenKey(firewall.PreroutingFormat), + firewall.GetInversePair(pair).GenKey(firewall.PreroutingFormat), + } + for _, key := range keys { + rule, ok := r.rules[key] + if !ok { + continue + } + if err := r.decrementSetCounter(rule); err != nil { + log.Warnf("rollback set counter for %s: %v", key, err) + } + delete(r.rules, key) + } +} + +// natRuleExprs resolves the match expressions of the pair's prerouting +// marking rule. It reserves the ipset references the matches need but queues +// nothing on the connection, so its error paths leave the connection clean. +func (r *family) natRuleExprs(pair firewall.RouterPair) ([]expr.Any, error) { + sourceExp, err := r.applyNetwork(pair.Source, nil, true) + if err != nil { + return nil, fmt.Errorf("apply source: %w", err) + } + + destExp, err := r.applyNetwork(pair.Destination, nil, false) + if err != nil { + r.dropNetworkMatch(sourceExp) + return nil, fmt.Errorf("apply destination: %w", err) + } + + op := expr.CmpOpEq + if pair.Inverse { + op = expr.CmpOpNeq + } + + exprs := []expr.Any{ + &expr.Meta{ + Key: expr.MetaKeyIIFNAME, + Register: 1, + }, + &expr.Cmp{ + Op: op, + Register: 1, + Data: ifname(r.wgIface.Name()), + }, + } + // We only care about NEW connections to mark them and later identify them in the postrouting chain for masquerading. + // Masquerading will take care of the conntrack state, which means we won't need to mark established connections. + exprs = append(exprs, getCtNewExprs()...) + + exprs = append(exprs, sourceExp...) + exprs = append(exprs, destExp...) + + markValue := nbnet.PreroutingFwmarkMasquerade + if pair.Inverse { + markValue = nbnet.PreroutingFwmarkMasqueradeReturn + } + + exprs = append(exprs, + &expr.Immediate{ + Register: 1, + Data: binaryutil.NativeEndian.PutUint32(markValue), + }, + &expr.Meta{ + Key: expr.MetaKeyMARK, + SourceRegister: true, + Register: 1, + }, + ) + + return exprs, nil +} + +// queueNatRule replaces any tracked rule for the pair and queues the new +// prerouting marking rule on the connection. Failures are logged rather than +// returned: the caller has already queued messages that only a Flush can +// commit, so it must not return early. +func (r *family) queueNatRule(pair firewall.RouterPair, exprs []expr.Any) { + ruleID := pair.GenKey(firewall.PreroutingFormat) + + if _, exists := r.rules[ruleID]; exists { + if err := r.removeNatRule(pair); err != nil { + // The rule this replaces may still be in the kernel. Keep tracking + // it and skip the new one: overwriting the entry would leave the old + // rule installed with nothing that can find it again, while keeping + // it lets the next update retry the whole replacement. + log.Errorf("replace prerouting rule %s: %v", ruleID, err) + r.dropNetworkMatch(exprs) + return + } + } + + // Ensure nat rules come first, so the mark can be overwritten. + // Currently overwritten by the dst-type LOCAL rules for redirected traffic. + r.rules[ruleID] = r.conn.InsertRule(&nftables.Rule{ + Table: r.workTable, + Chain: r.chains[chainNameManglePrerouting], + Exprs: exprs, + UserData: []byte(ruleID), + }) +} + +func (r *family) addPostroutingRules() { + // First masquerade rule for traffic coming in from WireGuard interface + exprs := []expr.Any{ + // Match on the first fwmark + &expr.Meta{ + Key: expr.MetaKeyMARK, + Register: 1, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: binaryutil.NativeEndian.PutUint32(nbnet.PreroutingFwmarkMasquerade), + }, + + // We need to exclude the loopback interface as this changes the ebpf proxy port + &expr.Meta{ + Key: expr.MetaKeyOIFNAME, + Register: 1, + }, + &expr.Cmp{ + Op: expr.CmpOpNeq, + Register: 1, + Data: ifname("lo"), + }, + &expr.Counter{}, + &expr.Masq{}, + } + + r.conn.AddRule(&nftables.Rule{ + Table: r.workTable, + Chain: r.chains[chainNameRoutingNat], + Exprs: exprs, + }) + + // Second masquerade rule for traffic going out through WireGuard interface + exprs2 := []expr.Any{ + // Match on the second fwmark + &expr.Meta{ + Key: expr.MetaKeyMARK, + Register: 1, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: binaryutil.NativeEndian.PutUint32(nbnet.PreroutingFwmarkMasqueradeReturn), + }, + + // Match WireGuard interface + &expr.Meta{ + Key: expr.MetaKeyOIFNAME, + Register: 1, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: ifname(r.wgIface.Name()), + }, + &expr.Counter{}, + &expr.Masq{}, + } + + r.conn.AddRule(&nftables.Rule{ + Table: r.workTable, + Chain: r.chains[chainNameRoutingNat], + Exprs: exprs2, + }) +} + +// addMSSClampingRules adds MSS clamping rules to prevent fragmentation for forwarded traffic. +func (r *family) addMSSClampingRules() error { + overhead := uint16(ipv4TCPHeaderSize) + if r.af.tableFamily == nftables.TableFamilyIPv6 { + overhead = ipv6TCPHeaderSize + } + if r.mtu <= overhead { + log.Debugf("MTU %d too small for MSS clamping (overhead %d), skipping", r.mtu, overhead) + return nil + } + mss := r.mtu - overhead + + exprsOut := []expr.Any{ + &expr.Meta{ + Key: expr.MetaKeyOIFNAME, + Register: 1, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: ifname(r.wgIface.Name()), + }, + &expr.Meta{ + Key: expr.MetaKeyL4PROTO, + Register: 1, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: []byte{unix.IPPROTO_TCP}, + }, + &expr.Payload{ + DestRegister: 1, + Base: expr.PayloadBaseTransportHeader, + Offset: 13, + Len: 1, + }, + &expr.Bitwise{ + DestRegister: 1, + SourceRegister: 1, + Len: 1, + Mask: []byte{0x02}, + Xor: []byte{0x00}, + }, + &expr.Cmp{ + Op: expr.CmpOpNeq, + Register: 1, + Data: []byte{0x00}, + }, + &expr.Counter{}, + &expr.Exthdr{ + DestRegister: 1, + Type: 2, + Offset: 2, + Len: 2, + Op: expr.ExthdrOpTcpopt, + }, + &expr.Cmp{ + Op: expr.CmpOpGt, + Register: 1, + Data: binaryutil.BigEndian.PutUint16(uint16(mss)), + }, + &expr.Immediate{ + Register: 1, + Data: binaryutil.BigEndian.PutUint16(uint16(mss)), + }, + &expr.Exthdr{ + SourceRegister: 1, + Type: 2, + Offset: 2, + Len: 2, + Op: expr.ExthdrOpTcpopt, + }, + } + + r.conn.AddRule(&nftables.Rule{ + Table: r.workTable, + Chain: r.chains[chainNameMangleForward], + Exprs: exprsOut, + }) + + return r.conn.Flush() +} + +func buildLegacyRouteRuleExpressions(sourceExp, destExp []expr.Any) []expr.Any { + exprs := make([]expr.Any, 0, len(sourceExp)+len(destExp)+2) + exprs = append(exprs, sourceExp...) + exprs = append(exprs, destExp...) + exprs = append(exprs, + &expr.Counter{}, + &expr.Verdict{Kind: expr.VerdictAccept}, + ) + return exprs +} + +// legacyRouteRuleExprs resolves the match expressions of the pair's legacy +// forwarding rule, queueing nothing on the connection. +func (r *family) legacyRouteRuleExprs(pair firewall.RouterPair) ([]expr.Any, error) { + sourceExp, err := r.applyNetwork(pair.Source, nil, true) + if err != nil { + return nil, fmt.Errorf("apply source: %w", err) + } + + destExp, err := r.applyNetwork(pair.Destination, nil, false) + if err != nil { + r.dropNetworkMatch(sourceExp) + return nil, fmt.Errorf("apply destination: %w", err) + } + + return buildLegacyRouteRuleExpressions(sourceExp, destExp), nil +} + +// queueLegacyRouteRule replaces any tracked rule for the pair and queues the +// new legacy forwarding rule. Failures are logged for the same reason as in +// queueNatRule. +func (r *family) queueLegacyRouteRule(pair firewall.RouterPair, exprs []expr.Any) { + ruleID := pair.GenKey(firewall.ForwardingFormat) + + if _, exists := r.rules[ruleID]; exists { + if err := r.removeLegacyRouteRule(pair); err != nil { + // Keep the old rule tracked instead of losing it, as in queueNatRule. + log.Errorf("replace legacy forwarding rule %s: %v", ruleID, err) + r.dropNetworkMatch(exprs) + return + } + } + + r.rules[ruleID] = r.conn.AddRule(&nftables.Rule{ + Table: r.workTable, + Chain: r.chains[chainNameRoutingFw], + Exprs: exprs, + UserData: []byte(ruleID), + }) +} + +// removeLegacyRouteRule removes a legacy routing rule for mgmt servers pre route acls +func (r *family) removeLegacyRouteRule(pair firewall.RouterPair) error { + ruleID := pair.GenKey(firewall.ForwardingFormat) + + rule, exists := r.rules[ruleID] + if !exists { + return nil + } + + return r.deleteLegacyRuleEntry(ruleID, rule) +} + +// deleteLegacyRuleEntry removes one legacy forwarding rule and drops its +// ipset references. It also clears stale entries that never got a handle. +func (r *family) deleteLegacyRuleEntry(ruleID firewall.RuleID, rule *nftables.Rule) error { + if rule.Handle == 0 { + log.Warnf("legacy forwarding rule %s has no handle, removing stale entry", ruleID) + if err := r.decrementSetCounter(rule); err != nil { + log.Warnf("decrement set counter for stale rule %s: %v", ruleID, err) + } + delete(r.rules, ruleID) + return nil + } + + if err := r.conn.DelRule(rule); err != nil { + return fmt.Errorf("remove legacy forwarding rule %s: %w", ruleID, err) + } + + delete(r.rules, ruleID) + + if err := r.decrementSetCounter(rule); err != nil { + return fmt.Errorf("decrement set counter: %w", err) + } + + return nil +} + +// GetLegacyManagement returns the route manager's legacy management mode +func (r *family) GetLegacyManagement() bool { + return r.legacyManagement +} + +// SetLegacyManagement sets the route manager to use legacy management mode +func (r *family) SetLegacyManagement(isLegacy bool) { + r.legacyManagement = isLegacy +} + +// RemoveAllLegacyRouteRules removes all legacy routing rules for mgmt servers pre route acls +func (r *family) RemoveAllLegacyRouteRules() error { + if err := r.refreshRulesMap(); err != nil { + return fmt.Errorf(refreshRulesMapError, err) + } + + var merr *multierror.Error + var found bool + for k, rule := range r.rules { + if !strings.HasPrefix(string(k), firewall.ForwardingFormatPrefix) { + continue + } + found = true + if err := r.deleteLegacyRuleEntry(k, rule); err != nil { + merr = multierror.Append(merr, err) + } + } + + // Commit the queued deletes here instead of leaving them for whichever + // caller flushes next: the tracking entries are already gone, so an + // uncommitted delete would leave a rule in the kernel that nothing can + // find again. + if found { + if err := r.conn.Flush(); err != nil { + merr = multierror.Append(merr, fmt.Errorf(flushError, err)) + } + } + + return nberrors.FormatErrorOrNil(merr) +} + +func (r *family) removeNatPreroutingRules() error { + table := &nftables.Table{ + Name: tableNat, + Family: r.af.tableFamily, + } + chain := &nftables.Chain{ + Name: chainNameNatPrerouting, + Table: table, + Hooknum: nftables.ChainHookPrerouting, + Priority: nftables.ChainPriorityNATDest, + Type: nftables.ChainTypeNAT, + } + rules, err := r.conn.GetRules(table, chain) + if err != nil { + return fmt.Errorf("get rules from nat table: %w", err) + } + + var merr *multierror.Error + + // Delete rules that have our UserData suffix + for _, rule := range rules { + if len(rule.UserData) == 0 || !strings.HasSuffix(string(rule.UserData), string(dnatSuffix)) { + continue + } + if err := r.conn.DelRule(rule); err != nil { + merr = multierror.Append(merr, fmt.Errorf("delete rule %s: %w", rule.UserData, err)) + } + } + + if err := r.conn.Flush(); err != nil { + merr = multierror.Append(merr, fmt.Errorf(flushError, err)) + } + return nberrors.FormatErrorOrNil(merr) +} + +func (r *family) RemoveNatRule(pair firewall.RouterPair) error { + if err := r.refreshRulesMap(); err != nil { + return fmt.Errorf(refreshRulesMapError, err) + } + + var merr *multierror.Error + + if pair.Masquerade { + if err := r.removeNatRule(pair); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove prerouting rule: %w", err)) + } + + if err := r.removeNatRule(firewall.GetInversePair(pair)); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove inverse prerouting rule: %w", err)) + } + } + + if err := r.removeLegacyRouteRule(pair); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove legacy routing rule: %w", err)) + } + + // Set counters are decremented in the sub-methods above before flush. If flush fails, + // counters will be off until the next successful removal or refresh cycle. + if err := r.conn.Flush(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("flush remove nat rules %s: %w", pair.Destination, err)) + } + + return nberrors.FormatErrorOrNil(merr) +} + +func (r *family) removeNatRule(pair firewall.RouterPair) error { + ruleID := pair.GenKey(firewall.PreroutingFormat) + + rule, exists := r.rules[ruleID] + if !exists { + log.Debugf("prerouting rule %s not found", ruleID) + return nil + } + + if rule.Handle == 0 { + log.Warnf("prerouting rule %s has no handle, removing stale entry", ruleID) + if err := r.decrementSetCounter(rule); err != nil { + log.Warnf("decrement set counter for stale rule %s: %v", ruleID, err) + } + delete(r.rules, ruleID) + return nil + } + + if err := r.conn.DelRule(rule); err != nil { + return fmt.Errorf("remove prerouting rule %s -> %s: %w", pair.Source, pair.Destination, err) + } + + log.Debugf("removed prerouting rule %s -> %s", pair.Source, pair.Destination) + + delete(r.rules, ruleID) + + if err := r.decrementSetCounter(rule); err != nil { + return fmt.Errorf("decrement set counter: %w", err) + } + + return nil +} diff --git a/client/firewall/nftables/rule_linux.go b/client/firewall/nftables/rule_linux.go index a90b74e36..8f3c0aebc 100644 --- a/client/firewall/nftables/rule_linux.go +++ b/client/firewall/nftables/rule_linux.go @@ -1,21 +1,26 @@ package nftables import ( - "net" + "net/netip" "github.com/google/nftables" + + "github.com/netbirdio/netbird/client/firewall/manager" ) -// Rule to handle management of rules +// Rule wraps an installed filter rule (peer or route). Source set +// membership is encoded in the rule's expressions; DeleteFilterRule +// recovers the set name via findSets so the refcounter can drop the +// right reference. mangleRule is set only for peer rules. type Rule struct { nftRule *nftables.Rule mangleRule *nftables.Rule - nftSet *nftables.Set - ruleID string - ip net.IP + // sources is the canonical source list this rule was created for. + sources []netip.Prefix + id manager.RuleID } -// GetRuleID returns the rule id -func (r *Rule) ID() string { - return r.ruleID +// ID returns the rule id +func (r *Rule) ID() manager.RuleID { + return r.id } diff --git a/client/firewall/nftables/testhelpers_linux_test.go b/client/firewall/nftables/testhelpers_linux_test.go new file mode 100644 index 000000000..72db3f7d2 --- /dev/null +++ b/client/firewall/nftables/testhelpers_linux_test.go @@ -0,0 +1,27 @@ +//go:build privileged + +package nftables + +import ( + "fmt" + "net" + "net/netip" +) + +func pfx(ip net.IP) []netip.Prefix { + if ip == nil { + return []netip.Prefix{netip.PrefixFrom(netip.IPv4Unspecified(), 0)} + } + if ip.IsUnspecified() { + if ip.To4() != nil { + return []netip.Prefix{netip.PrefixFrom(netip.IPv4Unspecified(), 0)} + } + return []netip.Prefix{netip.PrefixFrom(netip.IPv6Unspecified(), 0)} + } + a, ok := netip.AddrFromSlice(ip) + if !ok { + panic(fmt.Sprintf("invalid IP length: %d", len(ip))) + } + a = a.Unmap() + return []netip.Prefix{netip.PrefixFrom(a, a.BitLen())} +} diff --git a/client/firewall/uspfilter/allow_netbird.go b/client/firewall/uspfilter/allow_netbird.go deleted file mode 100644 index b120cdf12..000000000 --- a/client/firewall/uspfilter/allow_netbird.go +++ /dev/null @@ -1,37 +0,0 @@ -//go:build !windows - -package uspfilter - -import ( - log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/firewall/firewalld" - "github.com/netbirdio/netbird/client/internal/statemanager" -) - -// Close cleans up the firewall manager by removing all rules and closing trackers -func (m *Manager) Close(stateManager *statemanager.Manager) error { - m.mutex.Lock() - defer m.mutex.Unlock() - - m.resetState() - - if m.nativeFirewall != nil { - return m.nativeFirewall.Close(stateManager) - } - if err := firewalld.UntrustInterface(m.wgIface.Name()); err != nil { - log.Warnf("failed to untrust interface in firewalld: %v", err) - } - return nil -} - -// AllowNetbird allows netbird interface traffic -func (m *Manager) AllowNetbird() error { - if m.nativeFirewall != nil { - return m.nativeFirewall.AllowNetbird() - } - if err := firewalld.TrustInterface(m.wgIface.Name()); err != nil { - log.Warnf("failed to trust interface in firewalld: %v", err) - } - return nil -} diff --git a/client/firewall/uspfilter/common/iface.go b/client/firewall/uspfilter/common/iface.go deleted file mode 100644 index 9c06eb3f7..000000000 --- a/client/firewall/uspfilter/common/iface.go +++ /dev/null @@ -1,17 +0,0 @@ -package common - -import ( - wgdevice "golang.zx2c4.com/wireguard/device" - - "github.com/netbirdio/netbird/client/iface/device" - "github.com/netbirdio/netbird/client/iface/wgaddr" -) - -// IFaceMapper defines subset methods of interface required for manager -type IFaceMapper interface { - Name() string - SetFilter(device.PacketFilter) error - Address() wgaddr.Address - GetWGDevice() *wgdevice.Device - GetDevice() *device.FilteredDevice -} diff --git a/client/firewall/uspfilter/filter.go b/client/firewall/uspfilter/filter.go index 91866dcab..5e1366c1f 100644 --- a/client/firewall/uspfilter/filter.go +++ b/client/firewall/uspfilter/filter.go @@ -5,7 +5,6 @@ import ( "encoding/binary" "errors" "fmt" - "net" "net/netip" "os" "slices" @@ -20,14 +19,18 @@ import ( "github.com/google/uuid" "github.com/hashicorp/go-multierror" log "github.com/sirupsen/logrus" + wgdevice "golang.zx2c4.com/wireguard/device" nberrors "github.com/netbirdio/netbird/client/errors" + "github.com/netbirdio/netbird/client/firewall/firewalld" firewall "github.com/netbirdio/netbird/client/firewall/manager" "github.com/netbirdio/netbird/client/firewall/uspfilter/common" "github.com/netbirdio/netbird/client/firewall/uspfilter/conntrack" "github.com/netbirdio/netbird/client/firewall/uspfilter/forwarder" nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log" + "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/netstack" + "github.com/netbirdio/netbird/client/iface/wgaddr" nbid "github.com/netbirdio/netbird/client/internal/acl/id" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" "github.com/netbirdio/netbird/client/internal/statemanager" @@ -58,7 +61,10 @@ const ( // EnvDisableMSSClamping disables TCP MSS clamping for forwarded traffic. EnvDisableMSSClamping = "NB_DISABLE_MSS_CLAMPING" - // EnvForceUserspaceRouter forces userspace routing even if native routing is available. + // EnvForceUserspaceRouter is a deprecated alias for + // NB_FORCE_USERSPACE_FIREWALL: the userspace firewall always routes in + // userspace, so forcing one forces the other. Kept for backward + // compatibility. EnvForceUserspaceRouter = "NB_FORCE_USERSPACE_ROUTER" // EnvEnableLocalForwarding enables forwarding of local traffic to the native stack for internal (non-NetBird) interfaces. @@ -70,14 +76,20 @@ const ( EnvEnableNetstackLocalForwarding = "NB_ENABLE_NETSTACK_LOCAL_FORWARDING" ) -var errNatNotSupported = errors.New("nat not supported with userspace firewall") +// errNotSupported is returned by firewall operations that only make sense with +// a kernel firewall (kernel NAT/DNAT, eBPF) and are not implemented in +// userspace mode, where they should not be called. +var errNotSupported = errors.New("not supported with userspace firewall") -// RuleSet is a set of rules grouped by a string key -type RuleSet map[string]PeerRule +// peerRules is the canonical list-based storage for peer ACL rules. +// Drop and accept rules live in separate slices; drop-before-accept +// ordering comes from consulting the deny slice (and its index) before +// the accept one. +type peerRules []*PeerRule -type RouteRules []*RouteRule +type routeRules []*RouteRule -func (r RouteRules) Sort() { +func (r routeRules) Sort() { slices.SortStableFunc(r, func(a, b *RouteRule) int { // Deny rules come first if a.action == firewall.ActionDrop && b.action != firewall.ActionDrop { @@ -86,22 +98,74 @@ func (r RouteRules) Sort() { if a.action != firewall.ActionDrop && b.action == firewall.ActionDrop { return 1 } - return strings.Compare(a.id, b.id) + return strings.Compare(string(a.id), string(b.id)) }) } +// peerRuleSpec carries the parameters that define a peer filter rule, +// threaded together through the build path so the builders take a single +// argument instead of a long parameter list. +type peerRuleSpec struct { + mgmtID []byte + sources []netip.Prefix + ipLayer gopacket.LayerType + proto firewall.Protocol + sPort *firewall.Port + dPort *firewall.Port + action firewall.Action +} + +// Iface is the network interface the userspace firewall attaches to: the +// methods of the WireGuard device it actually uses. +type Iface interface { + Name() string + Address() wgaddr.Address + SetFilter(device.PacketFilter) error + GetWGDevice() *wgdevice.Device +} + +// InterfaceAllower opens the NetBird interface in the host firewall so it +// doesn't drop traffic the userspace firewall handles, without taking over +// packet filtering. Implementations (nftables, iptables, firewalld, the windows +// netsh rule) are selected per platform and injected into Create; Apply runs at +// creation and Close on teardown. +type InterfaceAllower interface { + Apply() error + Close() error +} + +// Config holds the dependencies and options for the userspace firewall. +type Config struct { + // IFace is the overlay interface the filter attaches to. + IFace Iface + // InterfaceAllower opens the NetBird interface in foreign kernel filter + // chains so the kernel doesn't drop traffic the userspace firewall handles. + // Nil in netstack mode, on non-Linux platforms without a backend, or when + // neither nftables nor iptables is available. firewalld trust is applied by + // the manager regardless, since firewalld owns its own chains and we cannot + // insert into them. + InterfaceAllower InterfaceAllower + // DisableServerRoutes indicates whether server routes are disabled. + DisableServerRoutes bool + FlowLogger nftypes.FlowLogger + MTU uint16 +} + // Manager userspace firewall manager type Manager struct { - outgoingRules map[netip.Addr]RuleSet - incomingDenyRules map[netip.Addr]RuleSet - incomingRules map[netip.Addr]RuleSet - routeRules RouteRules - routeRulesMap map[nbid.RuleID]*RouteRule - decoders sync.Pool - wgIface common.IFaceMapper - nativeFirewall firewall.Manager + decoders sync.Pool + wgIface Iface + ifaceAllower InterfaceAllower + mutex sync.RWMutex - mutex sync.RWMutex + incomingDenyRules peerRules + incomingAcceptRules peerRules + incomingDenyIndex peerRuleIndex + incomingAcceptIndex peerRuleIndex + peerRulesMap map[nbid.RuleID]*PeerRule + + routeRules routeRules + routeRulesMap map[nbid.RuleID]*RouteRule // indicates whether server routes are disabled disableServerRoutes bool @@ -121,6 +185,7 @@ type Manager struct { udpTracker *conntrack.UDPTracker icmpTracker *conntrack.ICMPTracker tcpTracker *conntrack.TCPTracker + fragments *fragmentTracker forwarder atomic.Pointer[forwarder.Forwarder] pendingCapture atomic.Pointer[forwarder.PacketCapture] logger *nblog.Logger @@ -183,22 +248,39 @@ func (d *decoder) decodePacket(data []byte) error { } } -// Create userspace firewall manager constructor -func Create(iface common.IFaceMapper, disableServerRoutes bool, flowLogger nftypes.FlowLogger, mtu uint16) (*Manager, error) { - return create(iface, nil, disableServerRoutes, flowLogger, mtu) -} - -func CreateWithNativeFirewall(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableServerRoutes bool, flowLogger nftypes.FlowLogger, mtu uint16) (*Manager, error) { - if nativeFirewall == nil { - return nil, errors.New("native firewall is nil") +// decodeTransport decodes the transport header of a first fragment (which +// gopacket leaves undecoded) into the decoder and appends its layer type to +// decoded, so the ACL pipeline can evaluate it like a normal packet. It returns +// false if the protocol is unsupported or the header is truncated. +func (d *decoder) decodeTransport(proto layers.IPProtocol, payload []byte) bool { + var l4 gopacket.DecodingLayer + var layerType gopacket.LayerType + var minLen int + switch proto { + case layers.IPProtocolTCP: + l4, layerType, minLen = &d.tcp, layers.LayerTypeTCP, 20 + case layers.IPProtocolUDP: + l4, layerType, minLen = &d.udp, layers.LayerTypeUDP, 8 + case layers.IPProtocolICMPv4: + l4, layerType, minLen = &d.icmp4, layers.LayerTypeICMPv4, 8 + case layers.IPProtocolICMPv6: + l4, layerType, minLen = &d.icmp6, layers.LayerTypeICMPv6, 8 + default: + return false } - mgr, err := create(iface, nativeFirewall, disableServerRoutes, flowLogger, mtu) - if err != nil { - return nil, err + // Reject a fragment too small to hold the full transport header before + // decoding: it can't be ACL-evaluated (tiny-fragment attack), and skipping + // the decode avoids gopacket allocating an error on the drop path. + if len(payload) < minLen { + return false } - return mgr, nil + if err := l4.DecodeFromBytes(payload, gopacket.NilDecodeFeedback); err != nil { + return false + } + d.decoded = append(d.decoded, layerType) + return true } func parseCreateEnv() (bool, bool, bool) { @@ -231,7 +313,7 @@ func parseCreateEnv() (bool, bool, bool) { return disableConntrack, enableLocalForwarding, disableMSSClamping } -func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableServerRoutes bool, flowLogger nftypes.FlowLogger, mtu uint16) (*Manager, error) { +func Create(cfg Config) (_ *Manager, err error) { disableConntrack, enableLocalForwarding, disableMSSClamping := parseCreateEnv() m := &Manager{ @@ -254,62 +336,133 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe return d }, }, - nativeFirewall: nativeFirewall, - outgoingRules: make(map[netip.Addr]RuleSet), - incomingDenyRules: make(map[netip.Addr]RuleSet), - incomingRules: make(map[netip.Addr]RuleSet), - wgIface: iface, + wgIface: cfg.IFace, + ifaceAllower: cfg.InterfaceAllower, localipmanager: newLocalIPManager(), - disableServerRoutes: disableServerRoutes, + disableServerRoutes: cfg.DisableServerRoutes, stateful: !disableConntrack, logger: nblog.NewFromLogrus(log.StandardLogger()), - flowLogger: flowLogger, + flowLogger: cfg.FlowLogger, netstack: netstack.IsEnabled(), localForwarding: enableLocalForwarding, + peerRulesMap: make(map[nbid.RuleID]*PeerRule), routeRulesMap: make(map[nbid.RuleID]*RouteRule), dnatMappings: make(map[netip.Addr]netip.Addr), portDNATRules: []portDNATRule{}, netstackServices: make(map[serviceKey]struct{}), - mtu: mtu, + mtu: cfg.MTU, } m.routingEnabled.Store(false) + // Release the allower (and its monitor) if setup fails after it was wired in. + defer func() { + if err != nil { + m.closeAllowerOnError() + } + }() + if !disableMSSClamping { - m.mssClampEnabled = true - if mtu > ipv4TCPHeaderMinSize { - m.mssClampValueIPv4 = mtu - ipv4TCPHeaderMinSize - } - if mtu > ipv6TCPHeaderMinSize { - m.mssClampValueIPv6 = mtu - ipv6TCPHeaderMinSize - } + m.enableMSSClamping(cfg.MTU) } - if err := m.localipmanager.UpdateLocalIPs(iface); err != nil { + if err := m.localipmanager.UpdateLocalIPs(cfg.IFace); err != nil { return nil, fmt.Errorf("update local IPs: %w", err) } - if disableConntrack { - log.Info("conntrack is disabled") - } else { - m.udpTracker = conntrack.NewUDPTracker(conntrack.DefaultUDPTimeout, m.logger, flowLogger) - m.icmpTracker = conntrack.NewICMPTracker(conntrack.DefaultICMPTimeout, m.logger, flowLogger) - m.tcpTracker = conntrack.NewTCPTracker(conntrack.DefaultTCPTimeout, m.logger, flowLogger) - } + m.fragments = newFragmentTracker(m.logger) + m.setupConntrack(disableConntrack) if m.netstack && m.localForwarding { if err := m.initForwarder(); err != nil { log.Errorf("failed to initialize forwarder: %v", err) } } - if err := iface.SetFilter(m); err != nil { + if err := cfg.IFace.SetFilter(m); err != nil { + m.fragments.Close() return nil, fmt.Errorf("set filter: %w", err) } + + m.openHostFirewall(cfg.IFace.Name()) + return m, nil } +// closeAllowerOnError releases the allower (and its monitor) when Create fails +// after the allower was wired in. +func (m *Manager) closeAllowerOnError() { + if m.ifaceAllower == nil { + return + } + if err := m.ifaceAllower.Close(); err != nil { + log.Warnf("close interface allower after failed firewall setup: %v", err) + } +} + +// enableMSSClamping enables MSS clamping and computes the per-family clamp values. +func (m *Manager) enableMSSClamping(mtu uint16) { + m.mssClampEnabled = true + if mtu > ipv4TCPHeaderMinSize { + m.mssClampValueIPv4 = mtu - ipv4TCPHeaderMinSize + } + if mtu > ipv6TCPHeaderMinSize { + m.mssClampValueIPv6 = mtu - ipv6TCPHeaderMinSize + } +} + +// setupConntrack initializes the stateful trackers unless conntrack is disabled. +func (m *Manager) setupConntrack(disabled bool) { + if disabled { + log.Info("conntrack is disabled") + return + } + m.udpTracker = conntrack.NewUDPTracker(conntrack.DefaultUDPTimeout, m.logger, m.flowLogger) + m.icmpTracker = conntrack.NewICMPTracker(conntrack.DefaultICMPTimeout, m.logger, m.flowLogger) + m.tcpTracker = conntrack.NewTCPTracker(conntrack.DefaultTCPTimeout, m.logger, m.flowLogger) +} + +// openHostFirewall opens the NetBird interface in the kernel firewall so it +// doesn't drop traffic the userspace firewall handles. Best-effort: failures +// here shouldn't prevent the firewall from coming up. +func (m *Manager) openHostFirewall(ifaceName string) { + if m.ifaceAllower != nil { + if err := m.ifaceAllower.Apply(); err != nil { + log.Errorf("failed to allow netbird interface traffic: %v", err) + } + } + // firewalld owns its own chains we can't insert into, so trust the interface + // there in addition to the allower. Netstack has no kernel interface. + if !m.netstack { + if err := firewalld.TrustInterface(ifaceName); err != nil { + log.Warnf("failed to trust interface in firewalld: %v", err) + } + } +} + +// Close cleans up the firewall manager: removes rules, closes trackers, and +// closes the interface allower. +func (m *Manager) Close(*statemanager.Manager) error { + m.mutex.Lock() + defer m.mutex.Unlock() + + m.resetState() + + var merr *multierror.Error + if m.ifaceAllower != nil { + if err := m.ifaceAllower.Close(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("close interface allower: %w", err)) + } + } + if !m.netstack { + if err := firewalld.UntrustInterface(m.wgIface.Name()); err != nil { + merr = multierror.Append(merr, fmt.Errorf("untrust interface in firewalld: %w", err)) + } + } + return nberrors.FormatErrorOrNil(merr) +} + // blockInvalidRouted installs drop rules for traffic to the wg overlay that // arrives via the routing path. v4 and v6 are independent: a v6 install // failure leaves v4 protection in place (and vice versa) so the returned // slice always contains whatever was successfully installed, even on error. // Callers must persist the slice so DisableRouting can clean partial state. -func (m *Manager) blockInvalidRouted(iface common.IFaceMapper) ([]firewall.Rule, error) { +func (m *Manager) blockInvalidRouted(iface Iface) ([]firewall.Rule, error) { wgPrefix := iface.Address().Network log.Debugf("blocking invalid routed traffic for %s", wgPrefix) @@ -320,7 +473,7 @@ func (m *Manager) blockInvalidRouted(iface common.IFaceMapper) ([]firewall.Rule, } var rules []firewall.Rule - v4Rule, err := m.addRouteFiltering( + v4Rule, err := m.addRouteRule( nil, sources, firewall.Network{Prefix: wgPrefix}, @@ -336,7 +489,7 @@ func (m *Manager) blockInvalidRouted(iface common.IFaceMapper) ([]firewall.Rule, if v6Net.IsValid() { log.Debugf("blocking invalid routed traffic for %s", v6Net) - v6Rule, err := m.addRouteFiltering( + v6Rule, err := m.addRouteRule( nil, sources, firewall.Network{Prefix: v6Net}, @@ -357,20 +510,14 @@ func (m *Manager) blockInvalidRouted(iface common.IFaceMapper) ([]firewall.Rule, } func (m *Manager) determineRouting() error { - var disableUspRouting, forceUserspaceRouter bool - var err error + var disableUspRouting bool if val := os.Getenv(EnvDisableUserspaceRouting); val != "" { + var err error disableUspRouting, err = strconv.ParseBool(val) if err != nil { log.Warnf("failed to parse %s: %v", EnvDisableUserspaceRouting, err) } } - if val := os.Getenv(EnvForceUserspaceRouter); val != "" { - forceUserspaceRouter, err = strconv.ParseBool(val) - if err != nil { - log.Warnf("failed to parse %s: %v", EnvForceUserspaceRouter, err) - } - } switch { case disableUspRouting: @@ -385,26 +532,11 @@ func (m *Manager) determineRouting() error { log.Info("server routes are disabled") - case forceUserspaceRouter: - m.routingEnabled.Store(true) - m.nativeRouter.Store(false) - - log.Info("userspace routing is forced") - - case !m.netstack && m.nativeFirewall != nil: - // if the OS supports routing natively, then we don't need to filter/route ourselves - // netstack mode won't support native routing as there is no interface - - m.routingEnabled.Store(true) - m.nativeRouter.Store(true) - - log.Info("native routing is enabled") - default: m.routingEnabled.Store(true) m.nativeRouter.Store(false) - log.Info("userspace routing enabled by default") + log.Info("userspace routing enabled") } if m.routingEnabled.Load() && !m.nativeRouter.Load() { @@ -470,96 +602,118 @@ func (m *Manager) IsStateful() bool { return m.stateful } -func (m *Manager) AddNatRule(pair firewall.RouterPair) error { - if m.nativeRouter.Load() && m.nativeFirewall != nil { - return m.nativeFirewall.AddNatRule(pair) - } - +func (m *Manager) AddNatRule(firewall.RouterPair) error { // userspace routed packets are always SNATed to the inbound direction // TODO: implement outbound SNAT return nil } // RemoveNatRule removes a routing firewall rule -func (m *Manager) RemoveNatRule(pair firewall.RouterPair) error { - if m.nativeRouter.Load() && m.nativeFirewall != nil { - return m.nativeFirewall.RemoveNatRule(pair) - } +func (m *Manager) RemoveNatRule(firewall.RouterPair) error { return nil } -// AddPeerFiltering rule to the firewall -// -// If comment argument is empty firewall manager should set -// rule ID as comment for the rule -func (m *Manager) AddPeerFiltering( +// addPeerRule installs an input-chain rule that matches packets +// by source only. Called from AddFilterRule when the caller doesn't +// specify a destination. Sources are expected to share one address +// family; the family selects the ipLayer so the ICMP variant matches +// what the decoder produces. +func (m *Manager) addPeerRule( id []byte, - ip net.IP, + sources []netip.Prefix, proto firewall.Protocol, sPort *firewall.Port, dPort *firewall.Port, action firewall.Action, - _ string, -) ([]firewall.Rule, error) { - // TODO: fix in upper layers - i, ok := netip.AddrFromSlice(ip) - if !ok { - return nil, fmt.Errorf("invalid IP: %s", ip) - } - - i = i.Unmap() - r := PeerRule{ - id: uuid.New().String(), - mgmtId: id, - ip: i, - ipLayer: layers.LayerTypeIPv6, - matchByIP: true, - drop: action == firewall.ActionDrop, - } - if i.Is4() { - r.ipLayer = layers.LayerTypeIPv4 - } - - if s := r.ip.String(); s == "0.0.0.0" || s == "::" { - r.matchByIP = false - } - - r.sPort = sPort - r.dPort = dPort - - r.protoLayer = protoToLayer(proto, r.ipLayer) - - m.mutex.Lock() - var targetMap map[netip.Addr]RuleSet - if r.drop { - targetMap = m.incomingDenyRules - } else { - targetMap = m.incomingRules - } - - if _, ok := targetMap[r.ip]; !ok { - targetMap[r.ip] = make(RuleSet) - } - targetMap[r.ip][r.id] = r - m.mutex.Unlock() - return []firewall.Rule{&r}, nil -} - -func (m *Manager) AddRouteFiltering( - id []byte, - sources []netip.Prefix, - destination firewall.Network, - proto firewall.Protocol, - sPort, dPort *firewall.Port, - action firewall.Action, ) (firewall.Rule, error) { m.mutex.Lock() defer m.mutex.Unlock() - return m.addRouteFiltering(id, sources, destination, proto, sPort, dPort, action) + // Sources are a single family; normalize v4-mapped prefixes to plain + // v4 and pick the matching IP layer. A /0 source matches any address + // of its own family only, mirroring the kernel backends. + normalized := make([]netip.Prefix, len(sources)) + ipLayer := layers.LayerTypeIPv4 + for i, p := range sources { + normalized[i] = firewall.UnmapPrefix(p) + if normalized[i].Addr().Is6() { + ipLayer = layers.LayerTypeIPv6 + } + } + spec := peerRuleSpec{ + mgmtID: id, + sources: normalized, + ipLayer: ipLayer, + proto: proto, + sPort: sPort, + dPort: dPort, + action: action, + } + return m.addOnePeerRule(spec), nil } -func (m *Manager) addRouteFiltering( +// addOnePeerRule builds and registers a single-family peer rule, or +// returns the existing rule when one with the same content key is +// already installed. The caller must hold m.mutex. The content key is +// the shared GenerateRuleID with an empty destination, so peer rules +// dedup the same way route rules and the kernel backends do; it is +// order-independent, so callers passing the same sources in any order +// dedup to one rule. +// +// There is no refcount: a content key is installed once and deleted on +// the first DeleteFilterRule for that key. The caller must therefore +// key its own tracking by the returned rule id so add and delete stay +// balanced per content key; the acl manager does this via +// peerRulesPairs. +func (m *Manager) addOnePeerRule(spec peerRuleSpec) *PeerRule { + ruleID := nbid.GenerateRuleID(spec.sources, firewall.Network{}, spec.proto, spec.sPort, spec.dPort, spec.action) + if existing, ok := m.peerRulesMap[ruleID]; ok { + return existing + } + + rule := m.buildPeerRule(ruleID, spec) + m.registerPeerRule(rule) + return rule +} + +func (m *Manager) buildPeerRule(ruleID nbid.RuleID, spec peerRuleSpec) *PeerRule { + r := &PeerRule{ + id: ruleID, + mgmtId: spec.mgmtID, + sources: spec.sources, + action: spec.action, + srcPort: spec.sPort, + dstPort: spec.dPort, + } + r.sourceAddrs = make(map[netip.Addr]struct{}, len(spec.sources)) + for _, p := range spec.sources { + if p.Bits() == p.Addr().BitLen() { + r.sourceAddrs[p.Addr()] = struct{}{} + } + } + r.protoLayer = protoToLayer(spec.proto, spec.ipLayer) + return r +} + +// registerPeerRule records a freshly built peer rule in the matching +// slice, index, and dedup map. The caller must hold m.mutex. +func (m *Manager) registerPeerRule(r *PeerRule) { + if r.action == firewall.ActionDrop { + m.incomingDenyRules = append(m.incomingDenyRules, r) + m.incomingDenyIndex.add(r) + } else { + m.incomingAcceptRules = append(m.incomingAcceptRules, r) + m.incomingAcceptIndex.add(r) + } + m.peerRulesMap[r.id] = r +} + +// AddFilterRule is the unified entry point for both peer (input chain) +// and route (forward chain) filtering rules. The destination +// distinguishes the two semantics: a zero Network installs an +// input-side rule that matches by source only; a set Network installs +// a forward-side rule that also matches the destination. +func (m *Manager) AddFilterRule( id []byte, sources []netip.Prefix, destination firewall.Network, @@ -567,19 +721,49 @@ func (m *Manager) addRouteFiltering( sPort, dPort *firewall.Port, action firewall.Action, ) (firewall.Rule, error) { - if m.nativeRouter.Load() && m.nativeFirewall != nil { - return m.nativeFirewall.AddRouteFiltering(id, sources, destination, proto, sPort, dPort, action) + if len(sources) == 0 { + return nil, firewall.ErrNoSources } - ruleKey := nbid.GenerateRouteRuleKey(sources, destination, proto, sPort, dPort, action) + if destination.IsZero() { + return m.addPeerRule(id, sources, proto, sPort, dPort, action) + } - if existingRule, ok := m.routeRulesMap[ruleKey]; ok { + m.mutex.Lock() + defer m.mutex.Unlock() + return m.addRouteRule(id, sources, destination, proto, sPort, dPort, action) +} + +// DeleteFilterRule deletes a filtering rule. The rule's underlying type +// is used to route to the correct internal path. +func (m *Manager) DeleteFilterRule(rule firewall.Rule) error { + m.mutex.Lock() + defer m.mutex.Unlock() + + if r, ok := rule.(*PeerRule); ok { + return m.deletePeerRuleLocked(r) + } + + // Anything else is a route rule (matched on the forward path). + return m.deleteRouteRule(rule) +} + +func (m *Manager) addRouteRule( + id []byte, + sources []netip.Prefix, + destination firewall.Network, + proto firewall.Protocol, + sPort, dPort *firewall.Port, + action firewall.Action, +) (firewall.Rule, error) { + ruleID := nbid.GenerateRuleID(sources, destination, proto, sPort, dPort, action) + + if existingRule, ok := m.routeRulesMap[ruleID]; ok { return existingRule, nil } rule := RouteRule{ - // TODO: consolidate these IDs - id: string(ruleKey), + id: ruleID, mgmtId: id, sources: sources, dstSet: destination.Set, @@ -594,78 +778,58 @@ func (m *Manager) addRouteFiltering( m.routeRules = append(m.routeRules, &rule) m.routeRules.Sort() - m.routeRulesMap[ruleKey] = &rule + m.routeRulesMap[ruleID] = &rule return &rule, nil } -func (m *Manager) DeleteRouteRule(rule firewall.Rule) error { - m.mutex.Lock() - defer m.mutex.Unlock() - - return m.deleteRouteRule(rule) -} - func (m *Manager) deleteRouteRule(rule firewall.Rule) error { - if m.nativeRouter.Load() && m.nativeFirewall != nil { - return m.nativeFirewall.DeleteRouteRule(rule) + ruleID := rule.ID() + trimmed, _, ok := removeRuleByID(m.routeRules, ruleID) + if !ok { + return fmt.Errorf("route rule not found: %s", ruleID) } - - ruleKey := nbid.RuleID(rule.ID()) - if _, ok := m.routeRulesMap[ruleKey]; !ok { - return fmt.Errorf("route rule not found: %s", ruleKey) - } - - idx := slices.IndexFunc(m.routeRules, func(r *RouteRule) bool { - return r.id == string(ruleKey) - }) - if idx < 0 { - return fmt.Errorf("route rule not found in slice: %s", ruleKey) - } - - m.routeRules = slices.Delete(m.routeRules, idx, idx+1) - delete(m.routeRulesMap, ruleKey) + m.routeRules = trimmed + delete(m.routeRulesMap, ruleID) return nil } -// DeletePeerRule from the firewall by rule definition -func (m *Manager) DeletePeerRule(rule firewall.Rule) error { - m.mutex.Lock() - defer m.mutex.Unlock() +// deletePeerRuleLocked removes a peer rule from the matching slice, +// index, and dedup map. The caller must hold m.mutex. +func (m *Manager) deletePeerRuleLocked(r *PeerRule) error { + target, index := &m.incomingAcceptRules, &m.incomingAcceptIndex + if r.action == firewall.ActionDrop { + target, index = &m.incomingDenyRules, &m.incomingDenyIndex + } - r, ok := rule.(*PeerRule) + trimmed, stored, ok := removeRuleByID(*target, r.id) if !ok { - return fmt.Errorf("delete rule: invalid rule type: %T", rule) - } - - var sourceMap map[netip.Addr]RuleSet - if r.drop { - sourceMap = m.incomingDenyRules - } else { - sourceMap = m.incomingRules - } - - if ruleset, ok := sourceMap[r.ip]; ok { - if _, exists := ruleset[r.id]; !exists { - return fmt.Errorf("delete rule: no rule with such id: %v", r.id) - } - delete(ruleset, r.id) - if len(ruleset) == 0 { - delete(sourceMap, r.ip) - } - } else { return fmt.Errorf("delete rule: no rule with such id: %v", r.id) } - + *target = trimmed + index.remove(stored) + delete(m.peerRulesMap, r.id) return nil } -// SetLegacyManagement doesn't need to be implemented for this manager -func (m *Manager) SetLegacyManagement(isLegacy bool) error { - if m.nativeFirewall == nil { - return nil +// removeRuleByID removes the first rule whose id matches ruleID from +// rules, preserving order. It returns the trimmed slice, the removed +// rule, and whether a match was found. +func removeRuleByID[S ~[]T, T firewall.Rule](rules S, ruleID firewall.RuleID) (S, T, bool) { + idx := slices.IndexFunc(rules, func(r T) bool { return r.ID() == ruleID }) + var removed T + if idx < 0 { + return rules, removed, false } - return m.nativeFirewall.SetLegacyManagement(isLegacy) + removed = rules[idx] + return slices.Delete(rules, idx, idx+1), removed, true +} + +// SetLegacyManagement is a no-op for the userspace firewall: it only matters +// when an old management server can't send route firewall rules, which the +// userspace router doesn't rely on. +func (m *Manager) SetLegacyManagement(bool) error { + return nil } // Flush doesn't need to be implemented for this manager @@ -674,11 +838,14 @@ func (m *Manager) Flush() error { return nil } // resetState clears all firewall rules and closes connection trackers. // Must be called with m.mutex held. func (m *Manager) resetState() { - clear(m.outgoingRules) - clear(m.incomingDenyRules) - clear(m.incomingRules) + m.incomingDenyRules = m.incomingDenyRules[:0] + m.incomingAcceptRules = m.incomingAcceptRules[:0] + m.incomingDenyIndex.reset() + m.incomingAcceptIndex.reset() + clear(m.peerRulesMap) clear(m.routeRulesMap) m.routeRules = m.routeRules[:0] + m.blockRules = nil m.udpHookOut.Store(nil) m.tcpHookOut.Store(nil) @@ -694,6 +861,10 @@ func (m *Manager) resetState() { m.tcpTracker.Close() } + if m.fragments != nil { + m.fragments.Close() + } + if fwder := m.forwarder.Load(); fwder != nil { fwder.SetCapture(nil) fwder.Stop() @@ -708,21 +879,15 @@ func (m *Manager) resetState() { } } -// SetupEBPFProxyNoTrack creates notrack rules for eBPF proxy loopback traffic. -func (m *Manager) SetupEBPFProxyNoTrack(proxyPort, wgPort uint16) error { - if m.nativeFirewall == nil { - return nil - } - return m.nativeFirewall.SetupEBPFProxyNoTrack(proxyPort, wgPort) +// SetupEBPFProxyNoTrack is not supported by the userspace firewall: eBPF isn't +// used in userspace mode, so this should never be called. +func (m *Manager) SetupEBPFProxyNoTrack(uint16, uint16) error { + return errNotSupported } // UpdateSet updates the rule destinations associated with the given set // by merging the existing prefixes with the new ones, then deduplicating. func (m *Manager) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error { - if m.nativeRouter.Load() && m.nativeFirewall != nil { - return m.nativeFirewall.UpdateSet(set, prefixes) - } - m.mutex.Lock() defer m.mutex.Unlock() @@ -820,11 +985,11 @@ func (m *Manager) extractIPs(d *decoder) (srcIP, dstIP netip.Addr) { case layers.LayerTypeIPv4: src, _ := netip.AddrFromSlice(d.ip4.SrcIP) dst, _ := netip.AddrFromSlice(d.ip4.DstIP) - return src, dst + return src.Unmap(), dst.Unmap() case layers.LayerTypeIPv6: src, _ := netip.AddrFromSlice(d.ip6.SrcIP) dst, _ := netip.AddrFromSlice(d.ip6.DstIP) - return src, dst + return src.Unmap(), dst.Unmap() default: return netip.Addr{}, netip.Addr{} } @@ -1046,19 +1211,20 @@ func (m *Manager) filterInbound(packetData []byte, size int) bool { return true } - // TODO: pass fragments of routed packets to forwarder + // gopacket does not decode the transport header of any IP fragment, so + // fragments take a dedicated path: the first fragment's header is decoded + // and ACL-evaluated here, and the remaining fragments inherit its verdict. if fragment { - if m.logger.Enabled(nblog.LevelTrace) { - if d.decoded[0] == layers.LayerTypeIPv4 { - m.logger.Trace4("packet is a fragment: src=%v dst=%v id=%v flags=%v", - srcIP, dstIP, d.ip4.Id, d.ip4.Flags) - } else { - m.logger.Trace2("packet is an IPv6 fragment: src=%v dst=%v", srcIP, dstIP) - } - } - return false + return m.filterInboundFragment(d, srcIP, dstIP, size) } + return m.filterInboundDecoded(d, srcIP, dstIP, packetData, size) +} + +// filterInboundDecoded runs the ACL, DNAT and conntrack pipeline on a fully +// decoded (non-fragment) inbound packet. It returns true if the packet should +// be dropped. +func (m *Manager) filterInboundDecoded(d *decoder, srcIP, dstIP netip.Addr, packetData []byte, size int) bool { // TODO: optimize port DNAT by caching matched rules in conntrack if translated := m.translateInboundPortDNAT(packetData, d, srcIP, dstIP); translated { // Re-decode after port DNAT translation to update port information @@ -1089,33 +1255,226 @@ func (m *Manager) filterInbound(packetData []byte, size int) bool { return m.handleRoutedTraffic(d, srcIP, dstIP, packetData, size) } +// fragmentMeta holds the reassembly identity and layout of an IP fragment, +// extracted uniformly for IPv4 and IPv6. +type fragmentMeta struct { + key fragmentKey + // offset is the fragment offset in 8-byte units (zero for the first + // fragment). + offset uint16 + // moreFragments is the More Fragments bit. A first fragment with it unset is + // an IPv6 atomic fragment (a complete datagram, RFC 6946): it has no trailing + // fragments to inherit a verdict, so it must not be recorded. + moreFragments bool + proto layers.IPProtocol + // l4payload is the fragmentable payload of this fragment. For the first + // fragment it starts with the transport header. + l4payload []byte + // headerEndOctets is the first fragment's payload length in 8-byte units: + // the smallest offset a trailing fragment may start at without overlapping + // the inspected transport header. + headerEndOctets uint16 +} + +// fragmentMetadata extracts the fragment identity and layout from a decoded IP +// fragment. It returns false for fragments it can't interpret (e.g. an IPv6 +// fragment header shorter than 8 bytes), which are then dropped. +func fragmentMetadata(d *decoder, srcIP, dstIP netip.Addr) (fragmentMeta, bool) { + switch d.decoded[0] { + case layers.LayerTypeIPv4: + payload := d.ip4.Payload + return fragmentMeta{ + key: fragmentKey{srcIP: srcIP, dstIP: dstIP, id: uint32(d.ip4.Id), proto: uint8(d.ip4.Protocol)}, + offset: d.ip4.FragOffset, + moreFragments: d.ip4.Flags&layers.IPv4MoreFragments != 0, + proto: d.ip4.Protocol, + l4payload: payload, + headerEndOctets: octets(len(payload)), + }, true + + case layers.LayerTypeIPv6: + // IPv6 fragment extension header: 8 bytes, followed by the fragmentable + // payload. Layout: next header (1), reserved (1), offset+flags (2), id (4). + payload := d.ip6.Payload + if len(payload) < 8 { + return fragmentMeta{}, false + } + nextHeader := layers.IPProtocol(payload[0]) + offsetFlags := binary.BigEndian.Uint16(payload[2:4]) + id := binary.BigEndian.Uint32(payload[4:8]) + l4 := payload[8:] + return fragmentMeta{ + key: fragmentKey{srcIP: srcIP, dstIP: dstIP, id: id, proto: uint8(nextHeader)}, + offset: offsetFlags >> 3, + moreFragments: offsetFlags&1 != 0, + proto: nextHeader, + l4payload: l4, + headerEndOctets: octets(len(l4)), + }, true + + default: + return fragmentMeta{}, false + } +} + +// octets rounds a byte length up to whole 8-byte units, the granularity of the +// IP fragment offset field. +func octets(nbytes int) uint16 { + return uint16((nbytes + 7) / 8) +} + +// filterInboundFragment decides the fate of an IP fragment. gopacket stops +// decoding at the network layer for every fragment, so the first fragment's +// transport header is decoded and ACL-evaluated here and its verdict recorded; +// the remaining (headerless) fragments inherit that verdict. Anything that +// cannot be tied to an allowed, non-overlapping first fragment is dropped. +func (m *Manager) filterInboundFragment(d *decoder, srcIP, dstIP netip.Addr, size int) bool { + meta, ok := fragmentMetadata(d, srcIP, dstIP) + if !ok { + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace2("dropping unsupported fragment: src=%v dst=%v", srcIP, dstIP) + } + return true + } + + if meta.offset != 0 { + return m.filterTrailingFragment(meta, srcIP, dstIP) + } + + // A new first fragment supersedes any recorded verdict for this datagram, so + // a re-sent or overlapping offset-zero fragment can't inherit the old one. + m.fragments.poison(meta.key) + + // First fragment: decode its transport header so the ACL can evaluate it. A + // decode failure means the fragment is too small to hold the full transport + // header (RFC 1858 §3 tiny-fragment attack); it can't be evaluated, so drop it. + if !d.decodeTransport(meta.proto, meta.l4payload) { + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace3("dropping first fragment without full L4 header: src=%v dst=%v id=%v", + srcIP, dstIP, meta.key.id) + } + return true + } + + return m.filterFirstFragment(d, meta, srcIP, dstIP, size) +} + +// filterTrailingFragment applies a recorded first-fragment verdict to a +// non-first fragment. +func (m *Manager) filterTrailingFragment(meta fragmentMeta, srcIP, dstIP netip.Addr) bool { + switch m.fragments.verdict(meta.key, meta.offset) { + case fragmentAllow: + return false + case fragmentOverlap: + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace3("dropping overlapping fragment rewriting inspected header: src=%v dst=%v id=%v", + srcIP, dstIP, meta.key.id) + } + return true + default: + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace3("dropping fragment with no allowed first fragment: src=%v dst=%v id=%v", + srcIP, dstIP, meta.key.id) + } + return true + } +} + +// filterFirstFragment runs the verdict part of the inbound pipeline on a first +// fragment with its transport header decoded. It mirrors filterInboundDecoded +// but skips DNAT (port rewriting on fragments is unsupported) and forwarder +// injection (fragments are left to the stack to reassemble, not forwarded). +// Allowed fragments have their verdict recorded so the datagram's trailing +// fragments inherit it. +func (m *Manager) filterFirstFragment(d *decoder, meta fragmentMeta, srcIP, dstIP netip.Addr, size int) bool { + if m.stateful && m.isValidTrackedConnection(d, srcIP, dstIP, size) { + m.recordFirstFragment(meta) + return false + } + + if m.localipmanager.IsLocalIP(dstIP) { + ruleID, blocked := m.peerACLsBlock(srcIP, d, nil) + if blocked { + m.storeDropFlow("Dropping local first fragment (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", + d, srcIP, dstIP, ruleID, size) + return true + } + m.trackInbound(d, srcIP, dstIP, ruleID, size) + m.recordFirstFragment(meta) + return false + } + + if !m.routingEnabled.Load() { + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace2("Dropping routed fragment (routing disabled): src=%s dst=%s", srcIP, dstIP) + } + return true + } + if m.nativeRouter.Load() { + m.trackInbound(d, srcIP, dstIP, nil, size) + m.recordFirstFragment(meta) + return false + } + + // TODO: pass fragments of routed packets to the forwarder; until then + // allowed routed fragments go to the native stack. + srcPort, dstPort := getPortsFromPacket(d) + ruleID, pass := m.routeACLsPass(srcIP, dstIP, d.decoded[1], srcPort, dstPort) + if !pass { + m.storeDropFlow("Dropping routed first fragment (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", + d, srcIP, dstIP, ruleID, size) + return true + } + + m.recordFirstFragment(meta) + return false +} + +// recordFirstFragment caches an allowed first fragment's verdict for its +// trailing fragments to inherit. Atomic fragments (no More Fragments bit) are +// complete datagrams with no trailing fragments, so they are not cached and +// cannot exhaust the verdict table. +func (m *Manager) recordFirstFragment(meta fragmentMeta) { + if !meta.moreFragments { + return + } + m.fragments.recordAllowed(meta.key, meta.headerEndOctets) +} + +// storeDropFlow logs and records a netflow drop event for an inbound packet +// denied by the ACLs. msg is the trace format taking rule id, protocol, source +// and destination. +func (m *Manager) storeDropFlow(msg string, d *decoder, srcIP, dstIP netip.Addr, ruleID []byte, size int) { + pnum := getProtocolFromPacket(d) + srcPort, dstPort := getPortsFromPacket(d) + + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace6(msg, ruleID, pnum, srcIP, srcPort, dstIP, dstPort) + } + + m.flowLogger.StoreEvent(nftypes.EventFields{ + FlowID: uuid.New(), + Type: nftypes.TypeDrop, + RuleID: ruleID, + Direction: nftypes.Ingress, + Protocol: pnum, + SourceIP: srcIP, + DestIP: dstIP, + SourcePort: srcPort, + DestPort: dstPort, + // TODO: icmp type/code + RxPackets: 1, + RxBytes: uint64(size), + }) +} + // handleLocalTraffic handles local traffic. // If it returns true, the packet should be dropped. func (m *Manager) handleLocalTraffic(d *decoder, srcIP, dstIP netip.Addr, packetData []byte, size int) bool { ruleID, blocked := m.peerACLsBlock(srcIP, d, packetData) if blocked { - pnum := getProtocolFromPacket(d) - srcPort, dstPort := getPortsFromPacket(d) - - if m.logger.Enabled(nblog.LevelTrace) { - m.logger.Trace6("Dropping local packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", - ruleID, pnum, srcIP, srcPort, dstIP, dstPort) - } - - m.flowLogger.StoreEvent(nftypes.EventFields{ - FlowID: uuid.New(), - Type: nftypes.TypeDrop, - RuleID: ruleID, - Direction: nftypes.Ingress, - Protocol: pnum, - SourceIP: srcIP, - DestIP: dstIP, - SourcePort: srcPort, - DestPort: dstPort, - // TODO: icmp type/code - RxPackets: 1, - RxBytes: uint64(size), - }) + m.storeDropFlow("Dropping local packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", + d, srcIP, dstIP, ruleID, size) return true } @@ -1168,27 +1527,8 @@ func (m *Manager) handleRoutedTraffic(d *decoder, srcIP, dstIP netip.Addr, packe ruleID, pass := m.routeACLsPass(srcIP, dstIP, protoLayer, srcPort, dstPort) if !pass { - proto := getProtocolFromPacket(d) - - if m.logger.Enabled(nblog.LevelTrace) { - m.logger.Trace6("Dropping routed packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", - ruleID, proto, srcIP, srcPort, dstIP, dstPort) - } - - m.flowLogger.StoreEvent(nftypes.EventFields{ - FlowID: uuid.New(), - Type: nftypes.TypeDrop, - RuleID: ruleID, - Direction: nftypes.Ingress, - Protocol: proto, - SourceIP: srcIP, - DestIP: dstIP, - SourcePort: srcPort, - DestPort: dstPort, - // TODO: icmp type/code - RxPackets: 1, - RxBytes: uint64(size), - }) + m.storeDropFlow("Dropping routed packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", + d, srcIP, dstIP, ruleID, size) return true } @@ -1404,20 +1744,12 @@ func (m *Manager) peerACLsBlock(srcIP netip.Addr, d *decoder, packetData []byte) return nil, false } - if mgmtId, filter, ok := validateRule(srcIP, packetData, m.incomingDenyRules[srcIP], d); ok { + if mgmtId, filter, ok := m.incomingDenyIndex.match(srcIP, d); ok { return mgmtId, filter } - - if mgmtId, filter, ok := validateRule(srcIP, packetData, m.incomingRules[srcIP], d); ok { + if mgmtId, filter, ok := m.incomingAcceptIndex.match(srcIP, d); ok { return mgmtId, filter } - if mgmtId, filter, ok := validateRule(srcIP, packetData, m.incomingRules[netip.IPv4Unspecified()], d); ok { - return mgmtId, filter - } - if mgmtId, filter, ok := validateRule(srcIP, packetData, m.incomingRules[netip.IPv6Unspecified()], d); ok { - return mgmtId, filter - } - return nil, true } @@ -1438,39 +1770,6 @@ func portsMatch(rulePort *firewall.Port, packetPort uint16) bool { return false } -func validateRule(ip netip.Addr, packetData []byte, rules map[string]PeerRule, d *decoder) ([]byte, bool, bool) { - payloadLayer := d.decoded[1] - - for _, rule := range rules { - if rule.matchByIP && ip.Compare(rule.ip) != 0 { - continue - } - - if rule.protoLayer == layerTypeAll { - return rule.mgmtId, rule.drop, true - } - - if !protoLayerMatches(rule.protoLayer, payloadLayer) { - continue - } - - switch payloadLayer { - case layers.LayerTypeTCP: - if portsMatch(rule.sPort, uint16(d.tcp.SrcPort)) && portsMatch(rule.dPort, uint16(d.tcp.DstPort)) { - return rule.mgmtId, rule.drop, true - } - case layers.LayerTypeUDP: - if portsMatch(rule.sPort, uint16(d.udp.SrcPort)) && portsMatch(rule.dPort, uint16(d.udp.DstPort)) { - return rule.mgmtId, rule.drop, true - } - case layers.LayerTypeICMPv4, layers.LayerTypeICMPv6: - return rule.mgmtId, rule.drop, true - } - } - - return nil, false, false -} - // routeACLsPass returns true if the packet is allowed by the route ACLs func (m *Manager) routeACLsPass(srcIP, dstIP netip.Addr, protoLayer gopacket.LayerType, srcPort, dstPort uint16) ([]byte, bool) { m.mutex.RLock() @@ -1547,10 +1846,13 @@ func (m *Manager) EnableRouting() error { } rules, err := m.blockInvalidRouted(m.wgIface) - // Persist whatever was installed even on partial failure, so DisableRouting - // can clean it up later. m.blockRules = rules if err != nil { + // Roll back so forwarding can't stay active without the full set of + // block rules. + if derr := m.disableRouting(); derr != nil { + log.Warnf("roll back routing after block rule failure: %v", derr) + } return fmt.Errorf("block invalid routed: %w", err) } @@ -1561,6 +1863,10 @@ func (m *Manager) DisableRouting() error { m.mutex.Lock() defer m.mutex.Unlock() + return m.disableRouting() +} + +func (m *Manager) disableRouting() error { fwder := m.forwarder.Load() if fwder == nil { return nil diff --git a/client/firewall/uspfilter/filter_bench_test.go b/client/firewall/uspfilter/filter_bench_test.go index 4dccb0f65..72f3417f2 100644 --- a/client/firewall/uspfilter/filter_bench_test.go +++ b/client/firewall/uspfilter/filter_bench_test.go @@ -94,7 +94,7 @@ func BenchmarkCoreFiltering(b *testing.B) { stateful: false, setupFunc: func(m *Manager) { // Single rule allowing all traffic - _, err := m.AddPeerFiltering(nil, net.ParseIP("0.0.0.0"), fw.ProtocolALL, nil, nil, fw.ActionAccept, "") + _, err := m.AddFilterRule(nil, pfx(net.ParseIP("0.0.0.0")), fw.Network{}, fw.ProtocolALL, nil, nil, fw.ActionAccept) require.NoError(b, err) }, desc: "Baseline: Single 'allow all' rule without connection tracking", @@ -114,15 +114,13 @@ func BenchmarkCoreFiltering(b *testing.B) { // Add explicit rules matching return traffic pattern for i := 0; i < 1000; i++ { // Simulate realistic ruleset size ip := generateRandomIPs(1)[0] - _, err := m.AddPeerFiltering( + _, err := m.AddFilterRule( nil, - ip, + pfx(ip), fw.Network{}, fw.ProtocolTCP, &fw.Port{Values: []uint16{uint16(1024 + i)}}, &fw.Port{Values: []uint16{80}}, - fw.ActionAccept, - "", - ) + fw.ActionAccept) require.NoError(b, err) } }, @@ -133,15 +131,13 @@ func BenchmarkCoreFiltering(b *testing.B) { stateful: true, setupFunc: func(m *Manager) { // Add some basic rules but rely on state for established connections - _, err := m.AddPeerFiltering( + _, err := m.AddFilterRule( nil, - net.ParseIP("0.0.0.0"), + pfx(net.ParseIP("0.0.0.0")), fw.Network{}, fw.ProtocolTCP, nil, nil, - fw.ActionDrop, - "", - ) + fw.ActionDrop) require.NoError(b, err) }, desc: "Connection tracking with established connections", @@ -168,9 +164,12 @@ func BenchmarkCoreFiltering(b *testing.B) { } // Create manager and basic setup - manager, _ := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) + require.NoError(b, err) defer b.Cleanup(func() { require.NoError(b, manager.Close(nil)) }) @@ -208,9 +207,12 @@ func BenchmarkStateScaling(b *testing.B) { for _, count := range connCounts { b.Run(fmt.Sprintf("conns_%d", count), func(b *testing.B) { - manager, _ := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) + require.NoError(b, err) b.Cleanup(func() { require.NoError(b, manager.Close(nil)) }) @@ -251,9 +253,12 @@ func BenchmarkEstablishmentOverhead(b *testing.B) { for _, sc := range scenarios { b.Run(sc.name, func(b *testing.B) { - manager, _ := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) + require.NoError(b, err) b.Cleanup(func() { require.NoError(b, manager.Close(nil)) }) @@ -409,9 +414,12 @@ func BenchmarkRoutedNetworkReturn(b *testing.B) { for _, sc := range scenarios { b.Run(sc.name, func(b *testing.B) { - manager, _ := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) + require.NoError(b, err) b.Cleanup(func() { require.NoError(b, manager.Close(nil)) }) @@ -536,9 +544,12 @@ func BenchmarkLongLivedConnections(b *testing.B) { require.NoError(b, os.Unsetenv("NB_DISABLE_CONNTRACK")) } - manager, _ := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) + require.NoError(b, err) defer b.Cleanup(func() { require.NoError(b, manager.Close(nil)) }) @@ -546,7 +557,7 @@ func BenchmarkLongLivedConnections(b *testing.B) { // Setup initial state based on scenario if sc.rules { // Single rule to allow all return traffic from port 80 - _, err := manager.AddPeerFiltering(nil, net.ParseIP("0.0.0.0"), fw.ProtocolTCP, &fw.Port{Values: []uint16{80}}, nil, fw.ActionAccept, "") + _, err := manager.AddFilterRule(nil, pfx(net.ParseIP("0.0.0.0")), fw.Network{}, fw.ProtocolTCP, &fw.Port{Values: []uint16{80}}, nil, fw.ActionAccept) require.NoError(b, err) } @@ -619,9 +630,12 @@ func BenchmarkShortLivedConnections(b *testing.B) { require.NoError(b, os.Unsetenv("NB_DISABLE_CONNTRACK")) } - manager, _ := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) + require.NoError(b, err) defer b.Cleanup(func() { require.NoError(b, manager.Close(nil)) }) @@ -629,7 +643,7 @@ func BenchmarkShortLivedConnections(b *testing.B) { // Setup initial state based on scenario if sc.rules { // Single rule to allow all return traffic from port 80 - _, err := manager.AddPeerFiltering(nil, net.ParseIP("0.0.0.0"), fw.ProtocolTCP, &fw.Port{Values: []uint16{80}}, nil, fw.ActionAccept, "") + _, err := manager.AddFilterRule(nil, pfx(net.ParseIP("0.0.0.0")), fw.Network{}, fw.ProtocolTCP, &fw.Port{Values: []uint16{80}}, nil, fw.ActionAccept) require.NoError(b, err) } @@ -730,16 +744,19 @@ func BenchmarkParallelLongLivedConnections(b *testing.B) { require.NoError(b, os.Unsetenv("NB_DISABLE_CONNTRACK")) } - manager, _ := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) + require.NoError(b, err) defer b.Cleanup(func() { require.NoError(b, manager.Close(nil)) }) // Setup initial state based on scenario if sc.rules { - _, err := manager.AddPeerFiltering(nil, net.ParseIP("0.0.0.0"), fw.ProtocolTCP, &fw.Port{Values: []uint16{80}}, nil, fw.ActionAccept, "") + _, err := manager.AddFilterRule(nil, pfx(net.ParseIP("0.0.0.0")), fw.Network{}, fw.ProtocolTCP, &fw.Port{Values: []uint16{80}}, nil, fw.ActionAccept) require.NoError(b, err) } @@ -810,15 +827,18 @@ func BenchmarkParallelShortLivedConnections(b *testing.B) { require.NoError(b, os.Unsetenv("NB_DISABLE_CONNTRACK")) } - manager, _ := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) + require.NoError(b, err) defer b.Cleanup(func() { require.NoError(b, manager.Close(nil)) }) if sc.rules { - _, err := manager.AddPeerFiltering(nil, net.ParseIP("0.0.0.0"), fw.ProtocolTCP, &fw.Port{Values: []uint16{80}}, nil, fw.ActionAccept, "") + _, err := manager.AddFilterRule(nil, pfx(net.ParseIP("0.0.0.0")), fw.Network{}, fw.ProtocolTCP, &fw.Port{Values: []uint16{80}}, nil, fw.ActionAccept) require.NoError(b, err) } @@ -931,7 +951,7 @@ func BenchmarkRouteACLs(b *testing.B) { for _, r := range rules { dst := fw.Network{Prefix: r.dest} - _, err := manager.AddRouteFiltering(nil, r.sources, dst, r.proto, nil, r.port, fw.ActionAccept) + _, err := manager.AddFilterRule(nil, r.sources, dst, r.proto, nil, r.port, fw.ActionAccept) if err != nil { b.Fatal(err) } @@ -1014,9 +1034,11 @@ func BenchmarkMSSClamping(b *testing.B) { for _, sc := range scenarios { b.Run(sc.name, func(b *testing.B) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(b, err) defer func() { require.NoError(b, manager.Close(nil)) @@ -1079,9 +1101,11 @@ func BenchmarkMSSClampingOverhead(b *testing.B) { for _, sc := range scenarios { b.Run(sc.name, func(b *testing.B) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(b, err) defer func() { require.NoError(b, manager.Close(nil)) @@ -1134,9 +1158,11 @@ func BenchmarkMSSClampingMemory(b *testing.B) { for _, sc := range scenarios { b.Run(sc.name, func(b *testing.B) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(b, err) defer func() { require.NoError(b, manager.Close(nil)) diff --git a/client/firewall/uspfilter/filter_filter_test.go b/client/firewall/uspfilter/filter_filter_test.go index a64c83138..57b59c9bc 100644 --- a/client/firewall/uspfilter/filter_filter_test.go +++ b/client/firewall/uspfilter/filter_filter_test.go @@ -5,7 +5,7 @@ import ( "net/netip" "testing" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/google/gopacket" "github.com/google/gopacket/layers" "github.com/stretchr/testify/require" @@ -32,7 +32,7 @@ func TestPeerACLFiltering(t *testing.T) { }, } - manager, err := Create(ifaceMock, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(t, err) require.NotNil(t, manager) @@ -496,40 +496,32 @@ func TestPeerACLFiltering(t *testing.T) { t.Run(tc.name, func(t *testing.T) { if tc.ruleAction == fw.ActionDrop { // add general accept rule for the same IP to test drop rule precedence - rules, err := manager.AddPeerFiltering( + rules, err := manager.AddFilterRule( nil, - net.ParseIP(tc.ruleIP), + pfx(net.ParseIP(tc.ruleIP)), fw.Network{}, fw.ProtocolALL, nil, nil, - fw.ActionAccept, - "", - ) + fw.ActionAccept) require.NoError(t, err) - require.NotEmpty(t, rules) + require.NotNil(t, rules) t.Cleanup(func() { - for _, rule := range rules { - require.NoError(t, manager.DeletePeerRule(rule)) - } + require.NoError(t, manager.DeleteFilterRule(rules)) }) } - rules, err := manager.AddPeerFiltering( + rules, err := manager.AddFilterRule( nil, - net.ParseIP(tc.ruleIP), + pfx(net.ParseIP(tc.ruleIP)), fw.Network{}, tc.ruleProto, tc.ruleSrcPort, tc.ruleDstPort, - tc.ruleAction, - "", - ) + tc.ruleAction) require.NoError(t, err) - require.NotEmpty(t, rules) + require.NotNil(t, rules) t.Cleanup(func() { - for _, rule := range rules { - require.NoError(t, manager.DeletePeerRule(rule)) - } + require.NoError(t, manager.DeleteFilterRule(rules)) }) packet := createTestPacket(t, tc.srcIP, tc.dstIP, tc.proto, tc.srcPort, tc.dstPort) @@ -557,7 +549,7 @@ func TestPeerACLFilteringIPv6(t *testing.T) { }, } - manager, err := Create(ifaceMock, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, manager.Close(nil)) }) @@ -652,14 +644,24 @@ func TestPeerACLFilteringIPv6(t *testing.T) { shouldBeBlocked: false, }, { - name: "IPv6: v4 wildcard ICMP rule matches ICMPv6 via protoLayerMatches", + name: "IPv6: v4 wildcard ICMP rule does not match ICMPv6", srcIP: "fd00::1", dstIP: "fd00::100", proto: fw.ProtocolICMP, ruleIP: "0.0.0.0", ruleProto: fw.ProtocolICMP, ruleAction: fw.ActionAccept, - shouldBeBlocked: false, + shouldBeBlocked: true, + }, + { + name: "IPv4: v6 wildcard ICMP rule does not match ICMPv4", + srcIP: "100.10.0.1", + dstIP: "100.10.0.100", + proto: fw.ProtocolICMP, + ruleIP: "::", + ruleProto: fw.ProtocolICMP, + ruleAction: fw.ActionAccept, + shouldBeBlocked: true, }, } @@ -672,22 +674,18 @@ func TestPeerACLFilteringIPv6(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { if tc.ruleAction == fw.ActionDrop { - rules, err := manager.AddPeerFiltering(nil, net.ParseIP(tc.ruleIP), fw.ProtocolALL, nil, nil, fw.ActionAccept, "") + rules, err := manager.AddFilterRule(nil, pfx(net.ParseIP(tc.ruleIP)), fw.Network{}, fw.ProtocolALL, nil, nil, fw.ActionAccept) require.NoError(t, err) t.Cleanup(func() { - for _, rule := range rules { - require.NoError(t, manager.DeletePeerRule(rule)) - } + require.NoError(t, manager.DeleteFilterRule(rules)) }) } - rules, err := manager.AddPeerFiltering(nil, net.ParseIP(tc.ruleIP), tc.ruleProto, nil, tc.ruleDstPort, tc.ruleAction, "") + rules, err := manager.AddFilterRule(nil, pfx(net.ParseIP(tc.ruleIP)), fw.Network{}, tc.ruleProto, nil, tc.ruleDstPort, tc.ruleAction) require.NoError(t, err) - require.NotEmpty(t, rules) + require.NotNil(t, rules) t.Cleanup(func() { - for _, rule := range rules { - require.NoError(t, manager.DeletePeerRule(rule)) - } + require.NoError(t, manager.DeleteFilterRule(rules)) }) packet := createTestPacket(t, tc.srcIP, tc.dstIP, tc.proto, tc.srcPort, tc.dstPort) @@ -800,7 +798,7 @@ func setupRoutedManager(tb testing.TB, network string) *Manager { }, } - manager, err := Create(ifaceMock, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(tb, err) require.NoError(tb, manager.EnableRouting()) require.NotNil(tb, manager) @@ -1405,7 +1403,7 @@ func TestRouteACLFiltering(t *testing.T) { t.Run(tc.name, func(t *testing.T) { if tc.rule.action == fw.ActionDrop { // add general accept rule to test drop rule - rule, err := manager.AddRouteFiltering( + rule, err := manager.AddFilterRule( nil, []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")}, fw.Network{Prefix: netip.MustParsePrefix("0.0.0.0/0")}, @@ -1415,13 +1413,13 @@ func TestRouteACLFiltering(t *testing.T) { fw.ActionAccept, ) require.NoError(t, err) - require.NotNil(t, rule) + require.NotEmpty(t, rule) t.Cleanup(func() { - require.NoError(t, manager.DeleteRouteRule(rule)) + require.NoError(t, manager.DeleteFilterRule(rule)) }) } - rule, err := manager.AddRouteFiltering( + rule, err := manager.AddFilterRule( nil, tc.rule.sources, tc.rule.dest, @@ -1431,10 +1429,10 @@ func TestRouteACLFiltering(t *testing.T) { tc.rule.action, ) require.NoError(t, err) - require.NotNil(t, rule) + require.NotEmpty(t, rule) t.Cleanup(func() { - require.NoError(t, manager.DeleteRouteRule(rule)) + require.NoError(t, manager.DeleteFilterRule(rule)) }) srcIP := netip.MustParseAddr(tc.srcIP) @@ -1602,9 +1600,9 @@ func TestRouteACLOrder(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - var rules []fw.Rule + var addedRules []fw.Rule for _, r := range tc.rules { - rule, err := manager.AddRouteFiltering( + rule, err := manager.AddFilterRule( nil, r.sources, r.dest, @@ -1615,12 +1613,12 @@ func TestRouteACLOrder(t *testing.T) { ) require.NoError(t, err) require.NotNil(t, rule) - rules = append(rules, rule) + addedRules = append(addedRules, rule) } t.Cleanup(func() { - for _, rule := range rules { - require.NoError(t, manager.DeleteRouteRule(rule)) + for _, rule := range addedRules { + require.NoError(t, manager.DeleteFilterRule(rule)) } }) @@ -1646,7 +1644,7 @@ func TestRouteACLSet(t *testing.T) { }, } - manager, err := Create(ifaceMock, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, manager.Close(nil)) @@ -1655,7 +1653,7 @@ func TestRouteACLSet(t *testing.T) { set := fw.NewDomainSet(domain.List{"example.org"}) // Add rule that uses the set (initially empty) - rule, err := manager.AddRouteFiltering( + rule, err := manager.AddFilterRule( nil, []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")}, fw.Network{Set: set}, @@ -1689,7 +1687,7 @@ func TestRouteACLFilteringIPv6(t *testing.T) { manager := setupRoutedManager(t, "10.10.0.100/16") v6Dst := netip.MustParsePrefix("fd00:dead:beef::/48") - _, err := manager.AddRouteFiltering( + _, err := manager.AddFilterRule( nil, []netip.Prefix{netip.MustParsePrefix("fd00::/16")}, fw.Network{Prefix: v6Dst}, @@ -1700,7 +1698,7 @@ func TestRouteACLFilteringIPv6(t *testing.T) { ) require.NoError(t, err) - _, err = manager.AddRouteFiltering( + _, err = manager.AddFilterRule( nil, []netip.Prefix{netip.MustParsePrefix("fd00::/16")}, fw.Network{Prefix: netip.MustParsePrefix("fd00:dead:beef:1::/64")}, diff --git a/client/firewall/uspfilter/filter_routeacl_test.go b/client/firewall/uspfilter/filter_routeacl_test.go index 449554d8b..2a0980e83 100644 --- a/client/firewall/uspfilter/filter_routeacl_test.go +++ b/client/firewall/uspfilter/filter_routeacl_test.go @@ -4,7 +4,7 @@ import ( "net/netip" "testing" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/google/gopacket/layers" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -29,7 +29,7 @@ func TestAddRouteFilteringReturnsExistingRule(t *testing.T) { destination := fw.Network{Prefix: netip.MustParsePrefix("192.168.1.0/24")} // Add rule first time - rule1, err := manager.AddRouteFiltering( + rule1, err := manager.AddFilterRule( []byte("policy-1"), sources, destination, @@ -42,7 +42,7 @@ func TestAddRouteFilteringReturnsExistingRule(t *testing.T) { require.NotNil(t, rule1) // Add the same rule again - rule2, err := manager.AddRouteFiltering( + rule2, err := manager.AddFilterRule( []byte("policy-1"), sources, destination, @@ -74,7 +74,7 @@ func TestAddRouteFilteringDifferentRulesGetDifferentIDs(t *testing.T) { sources := []netip.Prefix{netip.MustParsePrefix("100.64.1.0/24")} // Add first rule - rule1, err := manager.AddRouteFiltering( + rule1, err := manager.AddFilterRule( []byte("policy-1"), sources, fw.Network{Prefix: netip.MustParsePrefix("192.168.1.0/24")}, @@ -86,7 +86,7 @@ func TestAddRouteFilteringDifferentRulesGetDifferentIDs(t *testing.T) { require.NoError(t, err) // Add different rule (different destination) - rule2, err := manager.AddRouteFiltering( + rule2, err := manager.AddFilterRule( []byte("policy-2"), sources, fw.Network{Prefix: netip.MustParsePrefix("192.168.2.0/24")}, // Different! @@ -115,7 +115,7 @@ func TestRouteRuleUpdateDoesNotCauseGap(t *testing.T) { sources := []netip.Prefix{netip.MustParsePrefix("100.64.1.0/24")} destination := fw.Network{Prefix: netip.MustParsePrefix("192.168.1.0/24")} - rule1, err := manager.AddRouteFiltering( + rule1, err := manager.AddFilterRule( []byte("policy-1"), sources, destination, @@ -132,7 +132,7 @@ func TestRouteRuleUpdateDoesNotCauseGap(t *testing.T) { require.True(t, pass, "Traffic should pass with rule in place") // Re-add same rule (simulates network map update) - rule2, err := manager.AddRouteFiltering( + rule2, err := manager.AddFilterRule( []byte("policy-1"), sources, destination, @@ -147,7 +147,7 @@ func TestRouteRuleUpdateDoesNotCauseGap(t *testing.T) { // won't delete rule1 during cleanup. If IDs differed, deleting rule1 // would remove the only matching rule and cause a traffic gap. if rule1.ID() != rule2.ID() { - err = manager.DeleteRouteRule(rule1) + err = manager.DeleteFilterRule(rule1) require.NoError(t, err) } @@ -156,6 +156,59 @@ func TestRouteRuleUpdateDoesNotCauseGap(t *testing.T) { "Traffic should still pass after rule update - no gap should occur") } +// TestBlockInvalidRoutedDualStack verifies that when the interface has an +// IPv6 overlay address, blockInvalidRouted installs a block rule for both +// the v4 and v6 WG prefixes and that routed traffic to the v6 prefix is +// denied. The v4-only soft-skip path is covered by +// TestBlockInvalidRoutedIdempotent, whose mock leaves IPv6Net invalid. +func TestBlockInvalidRoutedDualStack(t *testing.T) { + ctrl := gomock.NewController(t) + dev := mocks.NewMockDevice(ctrl) + dev.EXPECT().MTU().Return(1500, nil).AnyTimes() + + wgNet := netip.MustParsePrefix("100.64.0.1/16") + wgNet6 := netip.MustParsePrefix("fd00:1234::1/64") + + ifaceMock := &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + AddressFunc: func() wgaddr.Address { + return wgaddr.Address{ + IP: wgNet.Addr(), + Network: wgNet, + IPv6: wgNet6.Addr(), + IPv6Net: wgNet6, + } + }, + GetDeviceFunc: func() *device.FilteredDevice { + return &device.FilteredDevice{Device: dev} + }, + GetWGDeviceFunc: func() *wgdevice.Device { + return &wgdevice.Device{} + }, + } + + manager, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: iface.DefaultMTU}) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, manager.Close(nil)) + }) + + rules, err := manager.blockInvalidRouted(ifaceMock) + require.NoError(t, err) + require.Len(t, rules, 2, "dual-stack interface must produce a v4 and a v6 block rule") + + manager.mutex.RLock() + ruleCount := len(manager.routeRules) + manager.mutex.RUnlock() + assert.Equal(t, 2, ruleCount, "should have one block rule per family") + + // v6 routed traffic to the WG prefix must be denied. + srcIP := netip.MustParseAddr("2001:db8::1") + dstIP := netip.MustParseAddr("fd00:1234::50") + _, pass := manager.routeACLsPass(srcIP, dstIP, layers.LayerTypeTCP, 12345, 80) + assert.False(t, pass, "block rule should deny routed traffic to the v6 WG prefix") +} + // TestBlockInvalidRoutedIdempotent verifies that blockInvalidRouted creates // exactly one drop rule for the WireGuard network prefix, and calling it again // returns the same rule without duplicating. @@ -182,7 +235,7 @@ func TestBlockInvalidRoutedIdempotent(t *testing.T) { }, } - manager, err := Create(ifaceMock, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, manager.Close(nil)) @@ -245,7 +298,7 @@ func TestBlockRuleNotAccumulatedOnRepeatedEnableRouting(t *testing.T) { }, } - manager, err := Create(ifaceMock, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, manager.Close(nil)) @@ -274,7 +327,7 @@ func TestRouteRuleCountStableAcrossUpdates(t *testing.T) { // Simulate 5 network map updates with the same route rule for i := 0; i < 5; i++ { - rule, err := manager.AddRouteFiltering( + rule, err := manager.AddFilterRule( []byte("policy-1"), sources, destination, @@ -304,7 +357,7 @@ func TestDeleteRouteRuleAfterIdempotentAdd(t *testing.T) { destination := fw.Network{Prefix: netip.MustParsePrefix("192.168.1.0/24")} // Add same rule twice - rule1, err := manager.AddRouteFiltering( + rule1, err := manager.AddFilterRule( []byte("policy-1"), sources, destination, @@ -315,7 +368,7 @@ func TestDeleteRouteRuleAfterIdempotentAdd(t *testing.T) { ) require.NoError(t, err) - rule2, err := manager.AddRouteFiltering( + rule2, err := manager.AddFilterRule( []byte("policy-1"), sources, destination, @@ -329,7 +382,7 @@ func TestDeleteRouteRuleAfterIdempotentAdd(t *testing.T) { require.Equal(t, rule1.ID(), rule2.ID(), "Should return same rule ID") // Delete using first reference - err = manager.DeleteRouteRule(rule1) + err = manager.DeleteFilterRule(rule1) require.NoError(t, err) // Verify traffic no longer passes @@ -364,7 +417,7 @@ func setupTestManager(t *testing.T) *Manager { }, } - manager, err := Create(ifaceMock, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(t, err) require.NoError(t, manager.EnableRouting()) diff --git a/client/firewall/uspfilter/filter_test.go b/client/firewall/uspfilter/filter_test.go index f19c4bb56..280ce9311 100644 --- a/client/firewall/uspfilter/filter_test.go +++ b/client/firewall/uspfilter/filter_test.go @@ -78,18 +78,19 @@ func TestManagerCreate(t *testing.T) { SetFilterFunc: func(device.PacketFilter) error { return nil }, } - m, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + m, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) if err != nil { t.Errorf("failed to create Manager: %v", err) return } + t.Cleanup(func() { require.NoError(t, m.Close(nil)) }) if m == nil { t.Error("Manager is nil") } } -func TestManagerAddPeerFiltering(t *testing.T) { +func TestManagerAddFilterRule(t *testing.T) { isSetFilterCalled := false ifaceMock := &IFaceMock{ SetFilterFunc: func(device.PacketFilter) error { @@ -98,18 +99,19 @@ func TestManagerAddPeerFiltering(t *testing.T) { }, } - m, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + m, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) if err != nil { t.Errorf("failed to create Manager: %v", err) return } + t.Cleanup(func() { require.NoError(t, m.Close(nil)) }) ip := net.ParseIP("192.168.1.1") proto := fw.ProtocolTCP port := &fw.Port{Values: []uint16{80}} action := fw.ActionDrop - rule, err := m.AddPeerFiltering(nil, ip, proto, nil, port, action, "") + rule, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, port, action) if err != nil { t.Errorf("failed to add filtering: %v", err) return @@ -131,74 +133,47 @@ func TestManagerDeleteRule(t *testing.T) { SetFilterFunc: func(device.PacketFilter) error { return nil }, } - m, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + m, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) if err != nil { t.Errorf("failed to create Manager: %v", err) return } + t.Cleanup(func() { require.NoError(t, m.Close(nil)) }) ip := netip.MustParseAddr("192.168.1.1") proto := fw.ProtocolTCP port := &fw.Port{Values: []uint16{80}} action := fw.ActionDrop - rule2, err := m.AddPeerFiltering(nil, ip.AsSlice(), proto, nil, port, action, "") + rule2, err := m.AddFilterRule(nil, pfx(ip.AsSlice()), fw.Network{}, proto, nil, port, action) if err != nil { t.Errorf("failed to add filtering: %v", err) return } - // Check rules exist in appropriate maps - for _, r := range rule2 { - peerRule, ok := r.(*PeerRule) - if !ok { - t.Errorf("rule should be a PeerRule") - continue - } - // Check if rule exists in deny or allow maps based on action - var found bool - if peerRule.drop { - _, found = m.incomingDenyRules[ip][r.ID()] - } else { - _, found = m.incomingRules[ip][r.ID()] - } - if !found { - t.Errorf("rule2 is not in the expected rules map") + peerRule, ok := rule2.(*PeerRule) + require.True(t, ok, "rule should be a peer rule") + + inMap := func() bool { + if peerRule.action == fw.ActionDrop { + return findRuleByID(m.incomingDenyRules, ip, rule2.ID()) } + return findRuleByID(m.incomingAcceptRules, ip, rule2.ID()) } - for _, r := range rule2 { - err = m.DeletePeerRule(r) - if err != nil { - t.Errorf("failed to delete rule: %v", err) - return - } - } + require.True(t, inMap(), "rule2 should be in the expected rules list") - // Check rules are removed from appropriate maps - for _, r := range rule2 { - peerRule, ok := r.(*PeerRule) - if !ok { - t.Errorf("rule should be a PeerRule") - continue - } - // Check if rule is removed from deny or allow maps based on action - var found bool - if peerRule.drop { - _, found = m.incomingDenyRules[ip][r.ID()] - } else { - _, found = m.incomingRules[ip][r.ID()] - } - if found { - t.Errorf("rule2 should be removed from the rules map") - } - } + require.NoError(t, m.DeleteFilterRule(rule2), "failed to delete rule") + + require.False(t, inMap(), "rule2 should be removed from the rules list") } func TestSetUDPPacketHook(t *testing.T) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, nbiface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, manager.Close(nil)) }) @@ -220,9 +195,11 @@ func TestSetUDPPacketHook(t *testing.T) { } func TestSetTCPPacketHook(t *testing.T) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, nbiface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, manager.Close(nil)) }) @@ -250,7 +227,7 @@ func TestPeerRuleLifecycleDenyRules(t *testing.T) { SetFilterFunc: func(device.PacketFilter) error { return nil }, } - m, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + m, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) require.NoError(t, err) defer func() { require.NoError(t, m.Close(nil)) @@ -260,36 +237,34 @@ func TestPeerRuleLifecycleDenyRules(t *testing.T) { addr := netip.MustParseAddr("192.168.1.1") // Add multiple deny rules for different ports - rule1, err := m.AddPeerFiltering(nil, ip, fw.ProtocolTCP, nil, - &fw.Port{Values: []uint16{22}}, fw.ActionDrop, "") + rule1, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{22}}, fw.ActionDrop) require.NoError(t, err) - rule2, err := m.AddPeerFiltering(nil, ip, fw.ProtocolTCP, nil, - &fw.Port{Values: []uint16{80}}, fw.ActionDrop, "") + rule2, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionDrop) require.NoError(t, err) m.mutex.RLock() - denyCount := len(m.incomingDenyRules[addr]) + denyCount := countRulesForAddr(m.incomingDenyRules, addr) m.mutex.RUnlock() require.Equal(t, 2, denyCount, "Should have exactly 2 deny rules") // Delete the first deny rule - err = m.DeletePeerRule(rule1[0]) + err = m.DeleteFilterRule(rule1) require.NoError(t, err) m.mutex.RLock() - denyCount = len(m.incomingDenyRules[addr]) + denyCount = countRulesForAddr(m.incomingDenyRules, addr) m.mutex.RUnlock() require.Equal(t, 1, denyCount, "Should have 1 deny rule after deleting first") // Delete the second deny rule - err = m.DeletePeerRule(rule2[0]) + err = m.DeleteFilterRule(rule2) require.NoError(t, err) m.mutex.RLock() - _, exists := m.incomingDenyRules[addr] + exists := countRulesForAddr(m.incomingDenyRules, addr) > 0 m.mutex.RUnlock() - require.False(t, exists, "Deny rules IP entry should be cleaned up when empty") + require.False(t, exists, "Deny rules should be cleaned up when empty") } // TestPeerRuleAddAndDeleteDontLeak verifies that repeatedly adding and deleting @@ -299,7 +274,7 @@ func TestPeerRuleAddAndDeleteDontLeak(t *testing.T) { SetFilterFunc: func(device.PacketFilter) error { return nil }, } - m, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + m, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) require.NoError(t, err) defer func() { require.NoError(t, m.Close(nil)) @@ -311,27 +286,21 @@ func TestPeerRuleAddAndDeleteDontLeak(t *testing.T) { // Simulate 10 network map updates: add rule, delete old, add new for i := 0; i < 10; i++ { // Add a deny rule - rules, err := m.AddPeerFiltering(nil, ip, fw.ProtocolTCP, nil, - &fw.Port{Values: []uint16{22}}, fw.ActionDrop, "") + rules, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{22}}, fw.ActionDrop) require.NoError(t, err) // Add an allow rule - allowRules, err := m.AddPeerFiltering(nil, ip, fw.ProtocolTCP, nil, - &fw.Port{Values: []uint16{80}}, fw.ActionAccept, "") + allowRules, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionAccept) require.NoError(t, err) // Delete them (simulating ACL manager cleanup) - for _, r := range rules { - require.NoError(t, m.DeletePeerRule(r)) - } - for _, r := range allowRules { - require.NoError(t, m.DeletePeerRule(r)) - } + require.NoError(t, m.DeleteFilterRule(rules)) + require.NoError(t, m.DeleteFilterRule(allowRules)) } m.mutex.RLock() - denyCount := len(m.incomingDenyRules[addr]) - allowCount := len(m.incomingRules[addr]) + denyCount := countRulesForAddr(m.incomingDenyRules, addr) + allowCount := countRulesForAddr(m.incomingAcceptRules, addr) m.mutex.RUnlock() require.Equal(t, 0, denyCount, "No deny rules should remain after cleanup") @@ -345,7 +314,7 @@ func TestMixedAllowDenyRulesSameIP(t *testing.T) { SetFilterFunc: func(device.PacketFilter) error { return nil }, } - m, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + m, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) require.NoError(t, err) defer func() { require.NoError(t, m.Close(nil)) @@ -354,41 +323,39 @@ func TestMixedAllowDenyRulesSameIP(t *testing.T) { ip := net.ParseIP("192.168.1.1") // Add allow rule for port 80 - allowRule, err := m.AddPeerFiltering(nil, ip, fw.ProtocolTCP, nil, - &fw.Port{Values: []uint16{80}}, fw.ActionAccept, "") + allowRule, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionAccept) require.NoError(t, err) // Add deny rule for port 22 - denyRule, err := m.AddPeerFiltering(nil, ip, fw.ProtocolTCP, nil, - &fw.Port{Values: []uint16{22}}, fw.ActionDrop, "") + denyRule, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{22}}, fw.ActionDrop) require.NoError(t, err) addr := netip.MustParseAddr("192.168.1.1") m.mutex.RLock() - allowCount := len(m.incomingRules[addr]) - denyCount := len(m.incomingDenyRules[addr]) + allowCount := countRulesForAddr(m.incomingAcceptRules, addr) + denyCount := countRulesForAddr(m.incomingDenyRules, addr) m.mutex.RUnlock() require.Equal(t, 1, allowCount, "Should have 1 allow rule") require.Equal(t, 1, denyCount, "Should have 1 deny rule") // Delete allow rule should not affect deny rule - err = m.DeletePeerRule(allowRule[0]) + err = m.DeleteFilterRule(allowRule) require.NoError(t, err) m.mutex.RLock() - denyCountAfter := len(m.incomingDenyRules[addr]) + denyCountAfter := countRulesForAddr(m.incomingDenyRules, addr) m.mutex.RUnlock() require.Equal(t, 1, denyCountAfter, "Deny rule should still exist after deleting allow rule") // Delete deny rule - err = m.DeletePeerRule(denyRule[0]) + err = m.DeleteFilterRule(denyRule) require.NoError(t, err) m.mutex.RLock() - _, denyExists := m.incomingDenyRules[addr] - _, allowExists := m.incomingRules[addr] + denyExists := countRulesForAddr(m.incomingDenyRules, addr) > 0 + allowExists := countRulesForAddr(m.incomingAcceptRules, addr) > 0 m.mutex.RUnlock() require.False(t, denyExists, "Deny rules should be empty") @@ -400,7 +367,7 @@ func TestManagerReset(t *testing.T) { SetFilterFunc: func(device.PacketFilter) error { return nil }, } - m, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + m, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) if err != nil { t.Errorf("failed to create Manager: %v", err) return @@ -411,7 +378,7 @@ func TestManagerReset(t *testing.T) { port := &fw.Port{Values: []uint16{80}} action := fw.ActionDrop - _, err = m.AddPeerFiltering(nil, ip, proto, nil, port, action, "") + _, err = m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, port, action) if err != nil { t.Errorf("failed to add filtering: %v", err) return @@ -423,7 +390,7 @@ func TestManagerReset(t *testing.T) { return } - if len(m.outgoingRules) != 0 || len(m.incomingRules) != 0 || len(m.incomingDenyRules) != 0 { + if len(m.incomingAcceptRules) != 0 || len(m.incomingDenyRules) != 0 { t.Errorf("rules are not empty") } } @@ -439,7 +406,7 @@ func TestNotMatchByIP(t *testing.T) { }, } - m, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + m, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) if err != nil { t.Errorf("failed to create Manager: %v", err) return @@ -449,7 +416,7 @@ func TestNotMatchByIP(t *testing.T) { proto := fw.ProtocolUDP action := fw.ActionAccept - _, err = m.AddPeerFiltering(nil, ip, proto, nil, nil, action, "") + _, err = m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, nil, action) if err != nil { t.Errorf("failed to add filtering: %v", err) return @@ -502,7 +469,7 @@ func TestRemovePacketHook(t *testing.T) { } // creating manager instance - manager, err := Create(iface, false, flowLogger, nbiface.DefaultMTU) + manager, err := Create(Config{IFace: iface, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) if err != nil { t.Fatalf("Failed to create Manager: %s", err) } @@ -519,9 +486,11 @@ func TestRemovePacketHook(t *testing.T) { } func TestProcessOutgoingHooks(t *testing.T) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, nbiface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) require.NoError(t, err) manager.udpTracker.Close() @@ -606,7 +575,7 @@ func TestUSPFilterCreatePerformance(t *testing.T) { ifaceMock := &IFaceMock{ SetFilterFunc: func(device.PacketFilter) error { return nil }, } - manager, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + manager, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) require.NoError(t, err) time.Sleep(time.Second) @@ -621,7 +590,7 @@ func TestUSPFilterCreatePerformance(t *testing.T) { start := time.Now() for i := 0; i < testMax; i++ { port := &fw.Port{Values: []uint16{uint16(1000 + i)}} - _, err = manager.AddPeerFiltering(nil, ip, "tcp", nil, port, fw.ActionAccept, "") + _, err = manager.AddFilterRule(nil, pfx(ip), fw.Network{}, "tcp", nil, port, fw.ActionAccept) require.NoError(t, err, "failed to add rule") } @@ -631,9 +600,11 @@ func TestUSPFilterCreatePerformance(t *testing.T) { } func TestStatefulFirewall_UDPTracking(t *testing.T) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, nbiface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) require.NoError(t, err) manager.udpTracker.Close() // Close the existing tracker @@ -845,7 +816,7 @@ func TestUpdateSetMerge(t *testing.T) { SetFilterFunc: func(device.PacketFilter) error { return nil }, } - manager, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + manager, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, manager.Close(nil)) @@ -858,7 +829,7 @@ func TestUpdateSetMerge(t *testing.T) { netip.MustParsePrefix("192.168.1.0/24"), } - rule, err := manager.AddRouteFiltering( + rule, err := manager.AddFilterRule( nil, []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")}, fw.Network{Set: set}, @@ -931,7 +902,7 @@ func TestUpdateSetDeduplication(t *testing.T) { SetFilterFunc: func(device.PacketFilter) error { return nil }, } - manager, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + manager, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, manager.Close(nil)) @@ -939,7 +910,7 @@ func TestUpdateSetDeduplication(t *testing.T) { set := fw.NewDomainSet(domain.List{"example.org"}) - rule, err := manager.AddRouteFiltering( + rule, err := manager.AddFilterRule( nil, []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")}, fw.Network{Set: set}, @@ -1051,7 +1022,7 @@ func TestMSSClamping(t *testing.T) { }, } - manager, err := Create(ifaceMock, false, flowLogger, 1280) + manager, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: 1280}) require.NoError(t, err) defer func() { require.NoError(t, manager.Close(nil)) @@ -1243,7 +1214,7 @@ func TestShouldForward(t *testing.T) { return wgaddr.Address{IP: wgIP, Network: netip.PrefixFrom(wgIP, 24)} } - manager, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + manager, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) require.NoError(t, err) defer func() { require.NoError(t, manager.Close(nil)) @@ -1358,7 +1329,7 @@ func TestShouldForward(t *testing.T) { // Re-create manager to pick up the new address with IPv6 require.NoError(t, manager.Close(nil)) - manager, err = Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + manager, err = Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) require.NoError(t, err) v6Cases := []struct { diff --git a/client/firewall/uspfilter/forwarder/forwarder.go b/client/firewall/uspfilter/forwarder/forwarder.go index 6291eb285..7308b38bd 100644 --- a/client/firewall/uspfilter/forwarder/forwarder.go +++ b/client/firewall/uspfilter/forwarder/forwarder.go @@ -5,11 +5,14 @@ import ( "fmt" "net" "net/netip" + "os" "runtime" + "strconv" "sync" "time" log "github.com/sirupsen/logrus" + wgdevice "golang.zx2c4.com/wireguard/device" "gvisor.dev/gvisor/pkg/buffer" "gvisor.dev/gvisor/pkg/tcpip" "gvisor.dev/gvisor/pkg/tcpip/header" @@ -20,9 +23,9 @@ import ( "gvisor.dev/gvisor/pkg/tcpip/transport/tcp" "gvisor.dev/gvisor/pkg/tcpip/transport/udp" - "github.com/netbirdio/netbird/client/firewall/uspfilter/common" "github.com/netbirdio/netbird/client/firewall/uspfilter/conntrack" nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log" + "github.com/netbirdio/netbird/client/iface/wgaddr" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" ) @@ -31,8 +34,19 @@ const ( defaultMaxInFlight = 1024 iosReceiveWindow = 16384 iosMaxInFlight = 256 + + // envForceTCPRACK overrides the platform default for gVisor's RACK loss + // detection. Set to a truthy value to force RACK on, or a falsy value to + // force it off, on any platform. + envForceTCPRACK = "NB_FORCE_TCP_RACK" ) +// IFace provides the WireGuard device and overlay addresses the forwarder needs. +type IFace interface { + GetWGDevice() *wgdevice.Device + Address() wgaddr.Address +} + type Forwarder struct { logger *nblog.Logger flowLogger nftypes.FlowLogger @@ -51,7 +65,7 @@ type Forwarder struct { pingSemaphore chan struct{} } -func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.FlowLogger, netstack bool, mtu uint16) (*Forwarder, error) { +func New(iface IFace, logger *nblog.Logger, flowLogger nftypes.FlowLogger, netstack bool, mtu uint16) (*Forwarder, error) { s := stack.New(stack.Options{ NetworkProtocols: []stack.NetworkProtocolFactory{ ipv4.NewProtocol, @@ -152,6 +166,8 @@ func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.Flow maxInFlight = iosMaxInFlight } + configureTCPRecovery(s) + tcpForwarder := tcp.NewForwarder(s, receiveWindow, maxInFlight, f.handleTCP) s.SetTransportProtocolHandler(tcp.ProtocolNumber, tcpForwarder.HandlePacket) @@ -466,3 +482,31 @@ func probeRawICMP(network, addr string, logger *nblog.Logger) bool { logger.Debug1("forwarder: raw %s socket access available", network) return true } + +// configureTCPRecovery disables gVisor's RACK loss detection on Windows, where +// it interacts poorly with the host and collapses throughput on routed TCP +// connections (gVisor issue #9778). Other platforms keep the default. The +// EnvForceTCPRACK environment variable overrides the platform default. +func configureTCPRecovery(s *stack.Stack) { + disableRACK := runtime.GOOS == "windows" + + if val := os.Getenv(envForceTCPRACK); val != "" { + force, err := strconv.ParseBool(val) + if err != nil { + log.Warnf("parse %s: %v", envForceTCPRACK, err) + } else { + disableRACK = !force + } + } + + if !disableRACK { + return + } + + opt := tcpip.TCPRecovery(0) + if err := s.SetTransportProtocolOption(tcp.ProtocolNumber, &opt); err != nil { + log.Warnf("disable TCP RACK loss detection: %v", err) + return + } + log.Info("forwarder: TCP RACK loss detection disabled") +} diff --git a/client/firewall/uspfilter/fragment.go b/client/firewall/uspfilter/fragment.go new file mode 100644 index 000000000..accc54365 --- /dev/null +++ b/client/firewall/uspfilter/fragment.go @@ -0,0 +1,204 @@ +package uspfilter + +import ( + "context" + "net/netip" + "os" + "strconv" + "sync" + "time" + + nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log" +) + +const ( + // defaultFragmentTimeout bounds how long a first-fragment verdict is kept + // while the remaining fragments arrive. It mirrors the Linux IP reassembly + // timeout (net.ipv4.ipfrag_time). + defaultFragmentTimeout = 30 * time.Second + // fragmentCleanupInterval is how often expired verdicts are purged. + fragmentCleanupInterval = 10 * time.Second + // defaultMaxFragmentEntries caps the number of concurrently tracked + // fragmented datagrams. The table stays bounded because each datagram is a + // single small entry regardless of how many fragments it is split into, and + // the 13-bit IPv4 fragment-offset field limits any datagram to 64 KiB. + defaultMaxFragmentEntries = 16384 + + // EnvFragmentMaxEntries overrides defaultMaxFragmentEntries. + EnvFragmentMaxEntries = "NB_FRAGMENT_MAX_ENTRIES" +) + +// fragmentVerdict is the decision for a trailing (headerless) fragment. +type fragmentVerdict int + +const ( + // fragmentDeny drops the fragment: no allowed first fragment is on record. + fragmentDeny fragmentVerdict = iota + // fragmentAllow passes the fragment: it belongs to an allowed datagram and + // does not overlap the already-inspected transport header. + fragmentAllow + // fragmentOverlap drops the fragment and poisons its datagram: it overlaps + // the transport header the ACL inspected (RFC 1858 §4, RFC 3128; RFC 5722 + // requires discarding the whole datagram on overlap for IPv6). + fragmentOverlap +) + +// fragmentKey identifies a fragmented datagram. It matches the RFC 791 / RFC +// 8200 reassembly key: source, destination, protocol and identification. The id +// is 32-bit to hold both the IPv4 (16-bit) and IPv6 (32-bit) identification. +type fragmentKey struct { + srcIP netip.Addr + dstIP netip.Addr + id uint32 + proto uint8 +} + +// fragmentEntry records the verdict of an allowed first fragment. +type fragmentEntry struct { + // headerEndOctets is the offset, in 8-byte units, at which the first + // fragment's payload ended. A trailing fragment starting before this + // overlaps bytes the ACL already inspected and is rejected. + headerEndOctets uint16 + // recordedAt is when the first fragment was accepted. The verdict expires a + // fixed timeout later and is not refreshed, mirroring the kernel reassembly + // timer so a trailing-fragment flood can't keep a datagram alive. + recordedAt time.Time +} + +// fragmentTracker records the ACL verdict of a datagram's first fragment so the +// remaining fragments, which carry no L4 header, can inherit the decision +// without reassembling the datagram. Only allowed first fragments are stored; +// anything that cannot be tied to an allowed, non-overlapping first fragment is +// dropped (fail closed). +type fragmentTracker struct { + logger *nblog.Logger + mutex sync.Mutex + entries map[fragmentKey]fragmentEntry + timeout time.Duration + // maxEntries caps the table; atCapacity dedups the capacity warning until + // the table drains below the cap again. + maxEntries int + atCapacity bool + cleanupTicker *time.Ticker + cancel context.CancelFunc +} + +func newFragmentTracker(logger *nblog.Logger) *fragmentTracker { + ctx, cancel := context.WithCancel(context.Background()) + t := &fragmentTracker{ + logger: logger, + entries: make(map[fragmentKey]fragmentEntry), + timeout: defaultFragmentTimeout, + maxEntries: fragmentMaxEntries(logger), + cleanupTicker: time.NewTicker(fragmentCleanupInterval), + cancel: cancel, + } + go t.cleanupRoutine(ctx) + return t +} + +func fragmentMaxEntries(logger *nblog.Logger) int { + v := os.Getenv(EnvFragmentMaxEntries) + if v == "" { + return defaultMaxFragmentEntries + } + n, err := strconv.Atoi(v) + if err != nil || n <= 0 { + logger.Warn2("invalid %s=%q, using default", EnvFragmentMaxEntries, v) + return defaultMaxFragmentEntries + } + return n +} + +// recordAllowed stores the verdict of an allowed first fragment. headerEndOctets +// is the first fragment's payload length in 8-byte units. When the table is full +// the record is dropped, which fails closed: the datagram's trailing fragments +// will be denied. +func (t *fragmentTracker) recordAllowed(key fragmentKey, headerEndOctets uint16) { + t.mutex.Lock() + defer t.mutex.Unlock() + + if t.entries == nil { + return + } + if _, ok := t.entries[key]; !ok && len(t.entries) >= t.maxEntries { + if !t.atCapacity { + t.atCapacity = true + t.logger.Warn2("fragment verdict table at capacity (%d/%d): trailing fragments of new datagrams will be dropped", + len(t.entries), t.maxEntries) + } + return + } + t.entries[key] = fragmentEntry{ + headerEndOctets: headerEndOctets, + recordedAt: time.Now(), + } +} + +// poison drops any recorded verdict for a datagram, so its later fragments are +// denied until a new allowed first fragment is recorded. Called on every +// offset-zero fragment to defeat offset-zero overlap rewrites (RFC 3128). +func (t *fragmentTracker) poison(key fragmentKey) { + t.mutex.Lock() + defer t.mutex.Unlock() + delete(t.entries, key) +} + +// verdict decides the fate of a trailing fragment at fragOffsetOctets (the IPv4 +// fragment offset, in 8-byte units). A fragment overlapping the inspected +// header poisons the datagram: the entry is removed so all further fragments of +// that datagram are denied too. +func (t *fragmentTracker) verdict(key fragmentKey, fragOffsetOctets uint16) fragmentVerdict { + t.mutex.Lock() + defer t.mutex.Unlock() + + entry, ok := t.entries[key] + if !ok { + return fragmentDeny + } + if time.Since(entry.recordedAt) > t.timeout { + delete(t.entries, key) + return fragmentDeny + } + if fragOffsetOctets < entry.headerEndOctets { + delete(t.entries, key) + return fragmentOverlap + } + return fragmentAllow +} + +func (t *fragmentTracker) cleanupRoutine(ctx context.Context) { + defer t.cleanupTicker.Stop() + for { + select { + case <-t.cleanupTicker.C: + t.cleanup() + case <-ctx.Done(): + return + } + } +} + +func (t *fragmentTracker) cleanup() { + t.mutex.Lock() + defer t.mutex.Unlock() + + for key, entry := range t.entries { + if time.Since(entry.recordedAt) > t.timeout { + delete(t.entries, key) + } + } + + if len(t.entries) < t.maxEntries { + t.atCapacity = false + } +} + +// Close stops the cleanup routine and releases resources. +func (t *fragmentTracker) Close() { + t.cancel() + + t.mutex.Lock() + t.entries = nil + t.mutex.Unlock() +} diff --git a/client/firewall/uspfilter/fragment_bench_test.go b/client/firewall/uspfilter/fragment_bench_test.go new file mode 100644 index 000000000..a9e6d2d13 --- /dev/null +++ b/client/firewall/uspfilter/fragment_bench_test.go @@ -0,0 +1,115 @@ +package uspfilter + +import ( + "encoding/binary" + "testing" +) + +// benchFilterInbound drives filterInbound over a fixed packet in a tight loop. +// Packets are built once, outside the timed region, so the benchmark measures +// only pipeline cost, which is what an attacker can amplify. +func benchFilterInbound(b *testing.B, pkt []byte) { + b.Helper() + b.ReportAllocs() + b.SetBytes(int64(len(pkt))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + m := benchManager + m.filterInbound(pkt, len(pkt)) + } +} + +// benchManager is a package-level manager reused across fragment benchmarks so +// setup cost stays out of the timed region. +var benchManager *Manager + +func setupBenchManager(b *testing.B) *Manager { + b.Helper() + m := newFragmentTestManager(b) + allowUDP(b, m, 8080) + // Disable conntrack so the allowed-first-fragment path measures transport + // decode + ACL every iteration instead of matching the connection tracked + // on the first iteration. + m.stateful = false + benchManager = m + return m +} + +// BenchmarkInbound_NormalPacket is the baseline: a full, non-fragmented UDP +// packet that passes the ACL. Fragment paths should stay comparable to this. +func BenchmarkInbound_NormalPacket(b *testing.B) { + setupBenchManager(b) + pkt := normalUDPPacket(b, 8080, 32) + benchFilterInbound(b, pkt) +} + +// BenchmarkInbound_FirstFragmentAllowed measures the first-fragment path: +// transport decode + ACL evaluation + verdict record. +func BenchmarkInbound_FirstFragmentAllowed(b *testing.B) { + setupBenchManager(b) + pkt := firstFragmentUDP(b, 0x2000, 8080, 32) + benchFilterInbound(b, pkt) +} + +// BenchmarkInbound_TrailingFragmentAllowed measures the common trailing-fragment +// path: a single map lookup after the first fragment is on record. +func BenchmarkInbound_TrailingFragmentAllowed(b *testing.B) { + m := setupBenchManager(b) + first := firstFragmentUDP(b, 0x3000, 8080, 32) + m.filterInbound(first, len(first)) + pkt := trailingFragment(b, 0x3000, 5, false, 24) + benchFilterInbound(b, pkt) +} + +// BenchmarkInbound_TrailingFragmentNoFirst is the primary DoS vector: an +// attacker floods trailing fragments with no first fragment on record. Each is +// a map miss and must be cheap. +func BenchmarkInbound_TrailingFragmentNoFirst(b *testing.B) { + setupBenchManager(b) + pkt := trailingFragment(b, 0x4000, 185, false, 40) + benchFilterInbound(b, pkt) +} + +// BenchmarkInbound_TinyFirstFragment measures the tiny-fragment drop path: a +// first fragment too small to decode a transport header. +func BenchmarkInbound_TinyFirstFragment(b *testing.B) { + setupBenchManager(b) + pkt := trailingFragment(b, 0x5000, 0, true, 4) + benchFilterInbound(b, pkt) +} + +// BenchmarkInbound_TrailingFragmentDistinctIDs is the worst case for the +// verdict table: an attacker varies the datagram id on every packet so no first +// fragment ever matches. Verdict lookups always miss and nothing is recorded, +// so the table cannot grow. Each iteration rewrites the id field in place. +func BenchmarkInbound_TrailingFragmentDistinctIDs(b *testing.B) { + setupBenchManager(b) + pkt := trailingFragment(b, 0x6000, 185, false, 40) + m := benchManager + + b.ReportAllocs() + b.SetBytes(int64(len(pkt))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + // IPv4 identification field is at bytes 4:6. + binary.BigEndian.PutUint16(pkt[4:6], uint16(i)) + m.filterInbound(pkt, len(pkt)) + } +} + +// BenchmarkInbound_FirstFragmentDistinctIDs measures sustained first-fragment +// pressure with distinct ids: transport decode + ACL + verdict insert until the +// table caps, exercising the map growth and capacity guard. +func BenchmarkInbound_FirstFragmentDistinctIDs(b *testing.B) { + setupBenchManager(b) + pkt := firstFragmentUDP(b, 0x7000, 8080, 32) + m := benchManager + + b.ReportAllocs() + b.SetBytes(int64(len(pkt))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + binary.BigEndian.PutUint16(pkt[4:6], uint16(i)) + m.filterInbound(pkt, len(pkt)) + } +} diff --git a/client/firewall/uspfilter/fragment_test.go b/client/firewall/uspfilter/fragment_test.go new file mode 100644 index 000000000..8f99864be --- /dev/null +++ b/client/firewall/uspfilter/fragment_test.go @@ -0,0 +1,554 @@ +package uspfilter + +import ( + "encoding/binary" + "net" + "net/netip" + "testing" + "time" + + "github.com/google/gopacket" + "github.com/google/gopacket/layers" + "github.com/stretchr/testify/require" + + fw "github.com/netbirdio/netbird/client/firewall/manager" + nbiface "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/device" + "github.com/netbirdio/netbird/client/iface/wgaddr" +) + +const ( + fragTestSrc = "100.10.0.1" + fragTestDst = "100.10.0.100" + fragTestSrcV6 = "fd00::1" + fragTestDstV6 = "fd00::100" +) + +func newFragmentTestManager(tb testing.TB) *Manager { + tb.Helper() + + ifaceMock := &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + AddressFunc: func() wgaddr.Address { + return wgaddr.Address{ + IP: netip.MustParseAddr(fragTestDst), + Network: netip.MustParsePrefix("100.10.0.0/16"), + IPv6: netip.MustParseAddr(fragTestDstV6), + IPv6Net: netip.MustParsePrefix("fd00::/64"), + } + }, + } + + m, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) + require.NoError(tb, err) + require.NoError(tb, m.UpdateLocalIPs()) + tb.Cleanup(func() { require.NoError(tb, m.Close(nil)) }) + return m +} + +// firstFragmentUDPTo builds the first fragment of a fragmented UDP datagram to +// the given destination: it carries the full UDP header plus payloadLen bytes +// of data, with the More Fragments flag set and offset zero. +func firstFragmentUDPTo(tb testing.TB, dst string, id uint16, dstPort uint16, payloadLen int) []byte { + tb.Helper() + + ip := &layers.IPv4{ + Version: 4, + TTL: 64, + Id: id, + Protocol: layers.IPProtocolUDP, + SrcIP: net.ParseIP(fragTestSrc), + DstIP: net.ParseIP(dst), + Flags: layers.IPv4MoreFragments, + } + udp := &layers.UDP{SrcPort: 40000, DstPort: layers.UDPPort(dstPort)} + require.NoError(tb, udp.SetNetworkLayerForChecksum(ip)) + + buf := gopacket.NewSerializeBuffer() + opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true} + require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, udp, gopacket.Payload(make([]byte, payloadLen)))) + return buf.Bytes() +} + +func firstFragmentUDP(tb testing.TB, id uint16, dstPort uint16, payloadLen int) []byte { + tb.Helper() + return firstFragmentUDPTo(tb, fragTestDst, id, dstPort, payloadLen) +} + +// firstFragmentTCP builds the first fragment of a fragmented TCP datagram: the +// full 20-byte TCP header plus 12 bytes of data, with the More Fragments flag +// set and offset zero. +func firstFragmentTCP(tb testing.TB, id uint16, dstPort uint16) []byte { + tb.Helper() + + ip := &layers.IPv4{ + Version: 4, + TTL: 64, + Id: id, + Protocol: layers.IPProtocolTCP, + SrcIP: net.ParseIP(fragTestSrc), + DstIP: net.ParseIP(fragTestDst), + Flags: layers.IPv4MoreFragments, + } + tcp := &layers.TCP{SrcPort: 40000, DstPort: layers.TCPPort(dstPort), SYN: true, Window: 64240} + require.NoError(tb, tcp.SetNetworkLayerForChecksum(ip)) + + buf := gopacket.NewSerializeBuffer() + opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true} + require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, tcp, gopacket.Payload(make([]byte, 12)))) + return buf.Bytes() +} + +// trailingFragmentTo builds a non-first fragment to the given destination: an +// IPv4 header at the given fragment offset (in 8-byte units) carrying raw +// payload and no L4 header. +func trailingFragmentTo(tb testing.TB, dst string, proto layers.IPProtocol, id uint16, fragOffsetOctets uint16, moreFragments bool, payloadLen int) []byte { + tb.Helper() + + ip := &layers.IPv4{ + Version: 4, + TTL: 64, + Id: id, + Protocol: proto, + SrcIP: net.ParseIP(fragTestSrc), + DstIP: net.ParseIP(dst), + FragOffset: fragOffsetOctets, + } + if moreFragments { + ip.Flags = layers.IPv4MoreFragments + } + + buf := gopacket.NewSerializeBuffer() + opts := gopacket.SerializeOptions{FixLengths: true} + require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, gopacket.Payload(make([]byte, payloadLen)))) + return buf.Bytes() +} + +func trailingFragment(tb testing.TB, id uint16, fragOffsetOctets uint16, moreFragments bool, payloadLen int) []byte { + tb.Helper() + return trailingFragmentTo(tb, fragTestDst, layers.IPProtocolUDP, id, fragOffsetOctets, moreFragments, payloadLen) +} + +// outboundUDPPacket builds a complete outbound UDP packet from the local +// address, used to establish conntrack state for reply-direction tests. +func outboundUDPPacket(tb testing.TB, srcPort, dstPort uint16) []byte { + tb.Helper() + + ip := &layers.IPv4{ + Version: 4, + TTL: 64, + Id: 1, + Protocol: layers.IPProtocolUDP, + SrcIP: net.ParseIP(fragTestDst), + DstIP: net.ParseIP(fragTestSrc), + } + udp := &layers.UDP{SrcPort: layers.UDPPort(srcPort), DstPort: layers.UDPPort(dstPort)} + require.NoError(tb, udp.SetNetworkLayerForChecksum(ip)) + + buf := gopacket.NewSerializeBuffer() + opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true} + require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, udp, gopacket.Payload(make([]byte, 16)))) + return buf.Bytes() +} + +// normalUDPPacket builds a complete, non-fragmented UDP packet for baseline +// comparisons against the fragment paths. +func normalUDPPacket(tb testing.TB, dstPort uint16, payloadLen int) []byte { + tb.Helper() + + ip := &layers.IPv4{ + Version: 4, + TTL: 64, + Id: 1, + Protocol: layers.IPProtocolUDP, + SrcIP: net.ParseIP(fragTestSrc), + DstIP: net.ParseIP(fragTestDst), + } + udp := &layers.UDP{SrcPort: 40000, DstPort: layers.UDPPort(dstPort)} + require.NoError(tb, udp.SetNetworkLayerForChecksum(ip)) + + buf := gopacket.NewSerializeBuffer() + opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true} + require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, udp, gopacket.Payload(make([]byte, payloadLen)))) + return buf.Bytes() +} + +func allowUDP(tb testing.TB, m *Manager, dstPort uint16) { + tb.Helper() + _, err := m.AddFilterRule(nil, pfx(net.ParseIP(fragTestSrc)), fw.Network{}, fw.ProtocolUDP, nil, + &fw.Port{Values: []uint16{dstPort}}, fw.ActionAccept) + require.NoError(tb, err) +} + +// TestFragment_TrailingWithoutFirstDropped is the core bypass repro: a trailing +// fragment with no allowed first fragment on record must be dropped. Before the +// fix, filterInbound returned false (allow) for any fragment. +func TestFragment_TrailingWithoutFirstDropped(t *testing.T) { + m := newFragmentTestManager(t) + + frag := trailingFragment(t, 0x1234, 185, false, 40) + require.True(t, m.filterInbound(frag, len(frag)), + "trailing fragment without an allowed first fragment must be dropped") +} + +// TestFragment_AllowedFirstPassesTrailing verifies that once a first fragment +// passes the ACL, its trailing fragments inherit the allow verdict. +func TestFragment_AllowedFirstPassesTrailing(t *testing.T) { + m := newFragmentTestManager(t) + allowUDP(t, m, 8080) + + // First fragment: UDP header (8) + 32 payload = 40 octets -> headerEnd = 5. + first := firstFragmentUDP(t, 0x2222, 8080, 32) + require.False(t, m.filterInbound(first, len(first)), + "allowed first fragment should pass and be recorded") + + trailing := trailingFragment(t, 0x2222, 5, false, 24) + require.False(t, m.filterInbound(trailing, len(trailing)), + "trailing fragment of an allowed datagram should pass") +} + +// TestFragment_DeniedFirstDropsTrailing verifies that a first fragment blocked +// by the ACL leaves no verdict, so its trailing fragments are dropped. +func TestFragment_DeniedFirstDropsTrailing(t *testing.T) { + m := newFragmentTestManager(t) + // No accept rule: local traffic defaults to deny. + + first := firstFragmentUDP(t, 0x3333, 9999, 32) + require.True(t, m.filterInbound(first, len(first)), + "first fragment to a blocked port should be dropped by the ACL") + + trailing := trailingFragment(t, 0x3333, 5, false, 24) + require.True(t, m.filterInbound(trailing, len(trailing)), + "trailing fragment of a denied datagram must be dropped") +} + +// TestFragment_OverlappingHeaderDropped covers the RFC 1858 §4 / RFC 3128 +// overlapping-fragment rewrite: a trailing fragment starting inside the range +// the ACL already inspected is dropped and poisons the datagram. TCP is used so +// the overlap lands on real header bytes (the flags at byte 13). +func TestFragment_OverlappingHeaderDropped(t *testing.T) { + m := newFragmentTestManager(t) + _, err := m.AddFilterRule(nil, pfx(net.ParseIP(fragTestSrc)), fw.Network{}, fw.ProtocolTCP, nil, + &fw.Port{Values: []uint16{8080}}, fw.ActionAccept) + require.NoError(t, err) + + // First fragment: TCP header (20) + 12 data = 32 bytes -> headerEnd = 4 octets. + first := firstFragmentTCP(t, 0x4444, 8080) + require.False(t, m.filterInbound(first, len(first))) + + // Overlapping fragment at offset 1 (byte 8) falls inside the inspected TCP + // header, so it could rewrite the flags or port on reassembly. + overlap := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x4444, 1, true, 32) + require.True(t, m.filterInbound(overlap, len(overlap)), + "fragment overlapping the inspected header must be dropped") + + // The datagram is now poisoned: a later, non-overlapping fragment is also + // dropped because the verdict was removed. + later := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x4444, 4, false, 24) + require.True(t, m.filterInbound(later, len(later)), + "fragments after an overlap must be dropped (datagram poisoned)") +} + +// TestFragment_OffsetZeroOverlapPoisons covers the RFC 3128 offset-zero rewrite: +// an allowed first fragment followed by a denied offset-zero fragment for the +// same datagram must not leave the earlier allow verdict in place. +func TestFragment_OffsetZeroOverlapPoisons(t *testing.T) { + m := newFragmentTestManager(t) + allowUDP(t, m, 8080) + + allowed := firstFragmentUDP(t, 0x5A5A, 8080, 32) + require.False(t, m.filterInbound(allowed, len(allowed)), + "allowed first fragment should pass and be recorded") + + // A second offset-zero fragment to a denied port supersedes the datagram's + // verdict; it is dropped and must not leave the allow in place. + denied := firstFragmentUDP(t, 0x5A5A, 9999, 32) + require.True(t, m.filterInbound(denied, len(denied)), + "denied offset-zero fragment must be dropped") + + trailing := trailingFragment(t, 0x5A5A, 5, false, 24) + require.True(t, m.filterInbound(trailing, len(trailing)), + "trailing fragment must be denied after the datagram was poisoned") +} + +// TestFragment_TinyFirstDropped covers the tiny-fragment attack: a first +// fragment too small to contain the full transport header can't be +// ACL-evaluated and must be dropped. +func TestFragment_TinyFirstDropped(t *testing.T) { + m := newFragmentTestManager(t) + allowUDP(t, m, 8080) + + // IPv4 header + 4 raw bytes, MF set, offset 0: too small for the 8-byte UDP + // header, so it decodes to L3 only. + tiny := trailingFragment(t, 0x5555, 0, true, 4) + require.True(t, m.filterInbound(tiny, len(tiny)), + "tiny first fragment without a full L4 header must be dropped") +} + +// TestFragment_TCPFirstFragment verifies the TCP arm of the transport decode: a +// first fragment carrying the full 20-byte TCP header is ACL-evaluated and its +// trailing fragments inherit the verdict. +func TestFragment_TCPFirstFragment(t *testing.T) { + m := newFragmentTestManager(t) + _, err := m.AddFilterRule(nil, pfx(net.ParseIP(fragTestSrc)), fw.Network{}, fw.ProtocolTCP, nil, + &fw.Port{Values: []uint16{8080}}, fw.ActionAccept) + require.NoError(t, err) + + // TCP header (20) + 12 data = 32 bytes -> headerEnd = 4 octets. + first := firstFragmentTCP(t, 0x6666, 8080) + require.False(t, m.filterInbound(first, len(first)), + "allowed TCP first fragment should pass and be recorded") + + trailing := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x6666, 4, false, 24) + require.False(t, m.filterInbound(trailing, len(trailing)), + "trailing fragment of an allowed TCP datagram should pass") +} + +// TestFragment_TCPTinyFirstDropped verifies the TCP minimum header length: 12 +// bytes would satisfy a UDP header but falls short of the 20-byte TCP header. +func TestFragment_TCPTinyFirstDropped(t *testing.T) { + m := newFragmentTestManager(t) + _, err := m.AddFilterRule(nil, pfx(net.ParseIP(fragTestSrc)), fw.Network{}, fw.ProtocolTCP, nil, + &fw.Port{Values: []uint16{8080}}, fw.ActionAccept) + require.NoError(t, err) + + tiny := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x7777, 0, true, 12) + require.True(t, m.filterInbound(tiny, len(tiny)), + "first fragment shorter than the TCP header must be dropped") +} + +// TestFragment_ConntrackAllowsFirstFragment verifies the conntrack branch: reply +// fragments of an outbound-established UDP flow pass without any inbound rule. +func TestFragment_ConntrackAllowsFirstFragment(t *testing.T) { + m := newFragmentTestManager(t) + + out := outboundUDPPacket(t, 12345, 40000) + require.False(t, m.filterOutbound(out, len(out))) + + first := firstFragmentUDP(t, 0x8888, 12345, 32) + require.False(t, m.filterInbound(first, len(first)), + "reply first fragment should pass via conntrack") + + trailing := trailingFragment(t, 0x8888, 5, false, 24) + require.False(t, m.filterInbound(trailing, len(trailing)), + "trailing fragment of a tracked flow should pass") +} + +// TestFragment_RoutingDisabledDropsFragment verifies routed first fragments are +// dropped when routing is disabled. +func TestFragment_RoutingDisabledDropsFragment(t *testing.T) { + m := newFragmentTestManager(t) + m.routingEnabled.Store(false) + + first := firstFragmentUDPTo(t, "198.51.100.10", 0x9999, 8080, 32) + require.True(t, m.filterInbound(first, len(first)), + "routed first fragment must be dropped when routing is disabled") +} + +// TestFragment_RouteACL verifies the route-ACL branch: fragments to a non-local +// destination follow the route rules, allowed datagrams pass their trailing +// fragments and denied ones don't. +func TestFragment_RouteACL(t *testing.T) { + m := newFragmentTestManager(t) + m.routingEnabled.Store(true) + m.nativeRouter.Store(false) + + _, err := m.AddFilterRule( + []byte("rt-1"), + []netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")}, + fw.Network{Prefix: netip.MustParsePrefix("198.51.100.0/24")}, + fw.ProtocolUDP, + nil, + &fw.Port{Values: []uint16{8080}}, + fw.ActionAccept, + ) + require.NoError(t, err) + + first := firstFragmentUDPTo(t, "198.51.100.10", 0xAAAA, 8080, 32) + require.False(t, m.filterInbound(first, len(first)), + "route-ACL-allowed first fragment should pass") + trailing := trailingFragmentTo(t, "198.51.100.10", layers.IPProtocolUDP, 0xAAAA, 5, false, 24) + require.False(t, m.filterInbound(trailing, len(trailing)), + "trailing fragment of an allowed routed datagram should pass") + + denied := firstFragmentUDPTo(t, "198.51.100.10", 0xBBBB, 9999, 32) + require.True(t, m.filterInbound(denied, len(denied)), + "route-ACL-denied first fragment must be dropped") + deniedTrailing := trailingFragmentTo(t, "198.51.100.10", layers.IPProtocolUDP, 0xBBBB, 5, false, 24) + require.True(t, m.filterInbound(deniedTrailing, len(deniedTrailing)), + "trailing fragment of a denied routed datagram must be dropped") +} + +// TestFragment_ExpiredVerdictDropsTrailing verifies a verdict older than the +// tracker timeout no longer admits trailing fragments. +func TestFragment_ExpiredVerdictDropsTrailing(t *testing.T) { + m := newFragmentTestManager(t) + allowUDP(t, m, 8080) + + first := firstFragmentUDP(t, 0xCCCC, 8080, 32) + require.False(t, m.filterInbound(first, len(first))) + + m.fragments.mutex.Lock() + for key, entry := range m.fragments.entries { + entry.recordedAt = time.Now().Add(-defaultFragmentTimeout - time.Second) + m.fragments.entries[key] = entry + } + m.fragments.mutex.Unlock() + + trailing := trailingFragment(t, 0xCCCC, 5, false, 24) + require.True(t, m.filterInbound(trailing, len(trailing)), + "trailing fragment after verdict expiry must be dropped") +} + +// TestFragment_CapacityFailsClosed verifies the table cap: at capacity, new +// datagram verdicts are not recorded (their trailing fragments are dropped) +// while already-recorded datagrams keep working. +func TestFragment_CapacityFailsClosed(t *testing.T) { + m := newFragmentTestManager(t) + allowUDP(t, m, 8080) + + m.fragments.mutex.Lock() + m.fragments.maxEntries = 1 + m.fragments.mutex.Unlock() + + first1 := firstFragmentUDP(t, 0x0101, 8080, 32) + require.False(t, m.filterInbound(first1, len(first1))) + + first2 := firstFragmentUDP(t, 0x0202, 8080, 32) + require.False(t, m.filterInbound(first2, len(first2)), + "first fragment itself still passes at capacity") + + trailing2 := trailingFragment(t, 0x0202, 5, false, 24) + require.True(t, m.filterInbound(trailing2, len(trailing2)), + "trailing fragment of an unrecorded datagram must be dropped at capacity") + + trailing1 := trailingFragment(t, 0x0101, 5, false, 24) + require.False(t, m.filterInbound(trailing1, len(trailing1)), + "already-recorded datagram should keep passing at capacity") +} + +// v6FragmentHeader builds the 8-byte IPv6 fragment extension header for the +// given inner protocol, offset (8-byte units), More Fragments bit and id. +func v6FragmentHeader(proto layers.IPProtocol, offsetOctets uint16, moreFragments bool, id uint32) []byte { + offsetFlags := offsetOctets << 3 + if moreFragments { + offsetFlags |= 1 + } + hdr := make([]byte, 8) + hdr[0] = uint8(proto) + binary.BigEndian.PutUint16(hdr[2:4], offsetFlags) + binary.BigEndian.PutUint32(hdr[4:8], id) + return hdr +} + +func v6UDPHeader(dstPort uint16, dataLen int) []byte { + hdr := make([]byte, 8) + binary.BigEndian.PutUint16(hdr[0:2], 40000) + binary.BigEndian.PutUint16(hdr[2:4], dstPort) + binary.BigEndian.PutUint16(hdr[4:6], uint16(8+dataLen)) + return hdr +} + +// firstFragmentUDPv6 builds the first fragment of a fragmented IPv6 UDP +// datagram: fragment header (offset 0, More Fragments set) + full UDP header + +// data. +func firstFragmentUDPv6(tb testing.TB, id uint32, dstPort uint16, dataLen int) []byte { + tb.Helper() + return fragmentUDPv6(tb, id, dstPort, dataLen, true) +} + +// fragmentUDPv6 builds an offset-zero IPv6 UDP fragment. With moreFragments +// false it is an atomic fragment (a complete datagram, RFC 6946). +func fragmentUDPv6(tb testing.TB, id uint32, dstPort uint16, dataLen int, moreFragments bool) []byte { + tb.Helper() + + ip := &layers.IPv6{ + Version: 6, + NextHeader: layers.IPProtocolIPv6Fragment, + HopLimit: 64, + SrcIP: net.ParseIP(fragTestSrcV6), + DstIP: net.ParseIP(fragTestDstV6), + } + payload := append(v6FragmentHeader(layers.IPProtocolUDP, 0, moreFragments, id), v6UDPHeader(dstPort, dataLen)...) + payload = append(payload, make([]byte, dataLen)...) + + buf := gopacket.NewSerializeBuffer() + require.NoError(tb, gopacket.SerializeLayers(buf, gopacket.SerializeOptions{FixLengths: true}, ip, gopacket.Payload(payload))) + return buf.Bytes() +} + +// trailingFragmentV6 builds a non-first IPv6 fragment: fragment header at the +// given offset carrying raw data and no transport header. +func trailingFragmentV6(tb testing.TB, id uint32, offsetOctets uint16, moreFragments bool, dataLen int) []byte { + tb.Helper() + + ip := &layers.IPv6{ + Version: 6, + NextHeader: layers.IPProtocolIPv6Fragment, + HopLimit: 64, + SrcIP: net.ParseIP(fragTestSrcV6), + DstIP: net.ParseIP(fragTestDstV6), + } + payload := append(v6FragmentHeader(layers.IPProtocolUDP, offsetOctets, moreFragments, id), make([]byte, dataLen)...) + + buf := gopacket.NewSerializeBuffer() + require.NoError(tb, gopacket.SerializeLayers(buf, gopacket.SerializeOptions{FixLengths: true}, ip, gopacket.Payload(payload))) + return buf.Bytes() +} + +// TestFragmentV6_TrailingWithoutFirstDropped verifies the IPv6 bypass is closed: +// a trailing fragment with no allowed first fragment is dropped. +func TestFragmentV6_TrailingWithoutFirstDropped(t *testing.T) { + m := newFragmentTestManager(t) + + frag := trailingFragmentV6(t, 0xAABBCCDD, 100, false, 40) + require.True(t, m.filterInbound(frag, len(frag)), + "IPv6 trailing fragment without an allowed first fragment must be dropped") +} + +// TestFragmentV6_AllowedFirstPassesTrailing verifies IPv6 fragments are +// evaluated like IPv4: an allowed first fragment lets its trailing fragments +// through. +func TestFragmentV6_AllowedFirstPassesTrailing(t *testing.T) { + m := newFragmentTestManager(t) + _, err := m.AddFilterRule(nil, pfx(net.ParseIP(fragTestSrcV6)), fw.Network{}, fw.ProtocolUDP, nil, + &fw.Port{Values: []uint16{8080}}, fw.ActionAccept) + require.NoError(t, err) + + // First fragment: UDP header (8) + 32 data = 40 octets -> headerEnd = 5. + first := firstFragmentUDPv6(t, 0xAABBCCDD, 8080, 32) + require.False(t, m.filterInbound(first, len(first)), + "allowed IPv6 first fragment should pass and be recorded") + + trailing := trailingFragmentV6(t, 0xAABBCCDD, 5, false, 24) + require.False(t, m.filterInbound(trailing, len(trailing)), + "trailing fragment of an allowed IPv6 datagram should pass") +} + +// TestFragmentV6_AtomicNotCached verifies an IPv6 atomic fragment (fragment +// header with offset 0 and no More Fragments, a complete datagram per RFC 6946) +// is evaluated but not recorded, so a flood of allowed atomic fragments can't +// exhaust the verdict table. +func TestFragmentV6_AtomicNotCached(t *testing.T) { + m := newFragmentTestManager(t) + _, err := m.AddFilterRule(nil, pfx(net.ParseIP(fragTestSrcV6)), fw.Network{}, fw.ProtocolUDP, nil, + &fw.Port{Values: []uint16{8080}}, fw.ActionAccept) + require.NoError(t, err) + + atomic := fragmentUDPv6(t, 0xA70301C, 8080, 16, false) + require.False(t, m.filterInbound(atomic, len(atomic)), + "allowed IPv6 atomic fragment should pass") + + m.fragments.mutex.Lock() + n := len(m.fragments.entries) + m.fragments.mutex.Unlock() + require.Zero(t, n, "atomic fragment must not create a verdict entry") + + // A genuine fragmented datagram (More Fragments set) is still recorded. + first := fragmentUDPv6(t, 0xBEEF, 8080, 32, true) + require.False(t, m.filterInbound(first, len(first))) + m.fragments.mutex.Lock() + n = len(m.fragments.entries) + m.fragments.mutex.Unlock() + require.Equal(t, 1, n, "genuine first fragment must record a verdict") +} diff --git a/client/firewall/uspfilter/allow_netbird_windows.go b/client/firewall/uspfilter/interface_allower_windows.go similarity index 79% rename from client/firewall/uspfilter/allow_netbird_windows.go rename to client/firewall/uspfilter/interface_allower_windows.go index 10a2b9116..7f525e28c 100644 --- a/client/firewall/uspfilter/allow_netbird_windows.go +++ b/client/firewall/uspfilter/interface_allower_windows.go @@ -9,7 +9,6 @@ import ( log "github.com/sirupsen/logrus" nberrors "github.com/netbirdio/netbird/client/errors" - "github.com/netbirdio/netbird/client/internal/statemanager" ) type action string @@ -20,35 +19,20 @@ const ( firewallRuleName = "Netbird" ) -// Close cleans up the firewall manager by removing all rules and closing trackers -func (m *Manager) Close(*statemanager.Manager) error { - m.mutex.Lock() - defer m.mutex.Unlock() - - m.resetState() - - if !isWindowsFirewallReachable() { - return nil - } - - var merr *multierror.Error - if isFirewallRuleActive(firewallRuleName) { - if err := manageFirewallRule(firewallRuleName, deleteRule); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove windows firewall rule: %w", err)) - } - } - - if isFirewallRuleActive(firewallRuleName + "-v6") { - if err := manageFirewallRule(firewallRuleName+"-v6", deleteRule); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove windows v6 firewall rule: %w", err)) - } - } - - return nberrors.FormatErrorOrNil(merr) +// WindowsInterfaceAllower opens the NetBird interface in the Windows firewall +// via netsh advfirewall rules. It implements InterfaceAllower for the userspace +// firewall on Windows. +type WindowsInterfaceAllower struct { + iface Iface } -// AllowNetbird allows netbird interface traffic -func (m *Manager) AllowNetbird() error { +// NewWindowsInterfaceAllower builds the Windows netsh-based interface allower. +func NewWindowsInterfaceAllower(iface Iface) *WindowsInterfaceAllower { + return &WindowsInterfaceAllower{iface: iface} +} + +// Apply adds inbound-allow netsh rules for the interface's addresses. +func (a *WindowsInterfaceAllower) Apply() error { if !isWindowsFirewallReachable() { return nil } @@ -60,13 +44,13 @@ func (m *Manager) AllowNetbird() error { "enable=yes", "action=allow", "profile=any", - "localip="+m.wgIface.Address().IP.String(), + "localip="+a.iface.Address().IP.String(), ); err != nil { return err } } - if v6 := m.wgIface.Address().IPv6; v6.IsValid() && !isFirewallRuleActive(firewallRuleName+"-v6") { + if v6 := a.iface.Address().IPv6; v6.IsValid() && !isFirewallRuleActive(firewallRuleName+"-v6") { if err := manageFirewallRule(firewallRuleName+"-v6", addRule, "dir=in", @@ -82,8 +66,27 @@ func (m *Manager) AllowNetbird() error { return nil } -func manageFirewallRule(ruleName string, action action, extraArgs ...string) error { +// Close removes the netsh rules added by Apply. +func (a *WindowsInterfaceAllower) Close() error { + if !isWindowsFirewallReachable() { + return nil + } + var merr *multierror.Error + if isFirewallRuleActive(firewallRuleName) { + if err := manageFirewallRule(firewallRuleName, deleteRule); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove windows firewall rule: %w", err)) + } + } + if isFirewallRuleActive(firewallRuleName + "-v6") { + if err := manageFirewallRule(firewallRuleName+"-v6", deleteRule); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove windows v6 firewall rule: %w", err)) + } + } + return nberrors.FormatErrorOrNil(merr) +} + +func manageFirewallRule(ruleName string, action action, extraArgs ...string) error { args := []string{"advfirewall", "firewall", string(action), "rule", "name=" + ruleName} if action == addRule { args = append(args, extraArgs...) diff --git a/client/firewall/uspfilter/localip.go b/client/firewall/uspfilter/localip.go index b35be56c6..869832732 100644 --- a/client/firewall/uspfilter/localip.go +++ b/client/firewall/uspfilter/localip.go @@ -7,8 +7,6 @@ import ( "sync/atomic" log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/firewall/uspfilter/common" ) // localIPSnapshot is an immutable snapshot of local IP addresses, swapped @@ -60,7 +58,7 @@ func processInterface(iface net.Interface, ips map[netip.Addr]struct{}, addresse } // UpdateLocalIPs rebuilds the local IP snapshot and swaps it in atomically. -func (m *localIPManager) UpdateLocalIPs(iface common.IFaceMapper) (err error) { +func (m *localIPManager) UpdateLocalIPs(iface Iface) (err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("panic: %v", r) diff --git a/client/firewall/uspfilter/nat.go b/client/firewall/uspfilter/nat.go index 5d51c1538..06312aabf 100644 --- a/client/firewall/uspfilter/nat.go +++ b/client/firewall/uspfilter/nat.go @@ -487,19 +487,13 @@ func incrementalUpdate(oldChecksum uint16, oldBytes, newBytes []byte) uint16 { } // AddDNATRule adds outbound DNAT rule for forwarding external traffic to NetBird network. -func (m *Manager) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) { - if m.nativeFirewall == nil { - return nil, errNatNotSupported - } - return m.nativeFirewall.AddDNATRule(rule) +func (m *Manager) AddDNATRule(firewall.ForwardRule) (firewall.Rule, error) { + return nil, errNotSupported } // DeleteDNATRule deletes outbound DNAT rule. -func (m *Manager) DeleteDNATRule(rule firewall.Rule) error { - if m.nativeFirewall == nil { - return errNatNotSupported - } - return m.nativeFirewall.DeleteDNATRule(rule) +func (m *Manager) DeleteDNATRule(firewall.Rule) error { + return errNotSupported } // addPortRedirection adds a port redirection rule. @@ -521,7 +515,6 @@ func (m *Manager) addPortRedirection(targetIP netip.Addr, protocol gopacket.Laye } // AddInboundDNAT adds an inbound DNAT rule redirecting traffic from NetBird peers to local services. -// TODO: also delegate to nativeFirewall when available for kernel WG mode func (m *Manager) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { var layerType gopacket.LayerType switch protocol { @@ -567,20 +560,16 @@ func (m *Manager) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Prot return m.removePortRedirection(localAddr, layerType, originalPort, translatedPort) } -// AddOutputDNAT delegates to the native firewall if available. -func (m *Manager) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { - if m.nativeFirewall == nil { - return fmt.Errorf("output DNAT not supported without native firewall") - } - return m.nativeFirewall.AddOutputDNAT(localAddr, protocol, originalPort, translatedPort) +// AddOutputDNAT is not supported by the userspace firewall: it backs kernel DNS +// redirection, but userspace DNS is served in-process on the gVisor netstack, so +// this should never be called. +func (m *Manager) AddOutputDNAT(netip.Addr, firewall.Protocol, uint16, uint16) error { + return errNotSupported } -// RemoveOutputDNAT delegates to the native firewall if available. -func (m *Manager) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { - if m.nativeFirewall == nil { - return nil - } - return m.nativeFirewall.RemoveOutputDNAT(localAddr, protocol, originalPort, translatedPort) +// RemoveOutputDNAT is a no-op for the userspace firewall (see AddOutputDNAT). +func (m *Manager) RemoveOutputDNAT(netip.Addr, firewall.Protocol, uint16, uint16) error { + return nil } // translateInboundPortDNAT applies port-specific DNAT translation to inbound packets. diff --git a/client/firewall/uspfilter/nat_bench_test.go b/client/firewall/uspfilter/nat_bench_test.go index 1e15c8c0c..422c6b849 100644 --- a/client/firewall/uspfilter/nat_bench_test.go +++ b/client/firewall/uspfilter/nat_bench_test.go @@ -64,9 +64,11 @@ func BenchmarkDNATTranslation(b *testing.B) { for _, sc := range scenarios { b.Run(sc.name, func(b *testing.B) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(b, err) defer func() { require.NoError(b, manager.Close(nil)) @@ -124,9 +126,11 @@ func BenchmarkDNATTranslation(b *testing.B) { // BenchmarkDNATConcurrency tests DNAT performance under concurrent load func BenchmarkDNATConcurrency(b *testing.B) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(b, err) defer func() { require.NoError(b, manager.Close(nil)) @@ -196,9 +200,11 @@ func BenchmarkDNATScaling(b *testing.B) { for _, count := range mappingCounts { b.Run(fmt.Sprintf("mappings_%d", count), func(b *testing.B) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(b, err) defer func() { require.NoError(b, manager.Close(nil)) @@ -308,9 +314,11 @@ func BenchmarkChecksumUpdate(b *testing.B) { // BenchmarkDNATMemoryAllocations checks for memory allocations in DNAT operations func BenchmarkDNATMemoryAllocations(b *testing.B) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(b, err) defer func() { require.NoError(b, manager.Close(nil)) @@ -481,9 +489,11 @@ func BenchmarkPortDNAT(b *testing.B) { for _, sc := range scenarios { b.Run(sc.name, func(b *testing.B) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(b, err) defer func() { require.NoError(b, manager.Close(nil)) diff --git a/client/firewall/uspfilter/nat_stateful_test.go b/client/firewall/uspfilter/nat_stateful_test.go index 21c6da06e..5fa5da027 100644 --- a/client/firewall/uspfilter/nat_stateful_test.go +++ b/client/firewall/uspfilter/nat_stateful_test.go @@ -13,9 +13,11 @@ import ( // TestPortDNATBasic tests basic port DNAT functionality func TestPortDNATBasic(t *testing.T) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(t, err) defer func() { require.NoError(t, manager.Close(nil)) @@ -49,9 +51,11 @@ func TestPortDNATBasic(t *testing.T) { // TestPortDNATMultipleRules tests multiple port DNAT rules func TestPortDNATMultipleRules(t *testing.T) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(t, err) defer func() { require.NoError(t, manager.Close(nil)) diff --git a/client/firewall/uspfilter/nat_test.go b/client/firewall/uspfilter/nat_test.go index 4598c3901..5b5840383 100644 --- a/client/firewall/uspfilter/nat_test.go +++ b/client/firewall/uspfilter/nat_test.go @@ -15,9 +15,11 @@ import ( // TestDNATTranslationCorrectness verifies DNAT translation works correctly func TestDNATTranslationCorrectness(t *testing.T) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(t, err) defer func() { require.NoError(t, manager.Close(nil)) @@ -104,9 +106,11 @@ func parsePacket(t testing.TB, packetData []byte) *decoder { // TestDNATMappingManagement tests adding/removing DNAT mappings func TestDNATMappingManagement(t *testing.T) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(t, err) defer func() { require.NoError(t, manager.Close(nil)) @@ -152,9 +156,11 @@ func TestDNATMappingManagement(t *testing.T) { } func TestInboundPortDNAT(t *testing.T) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(t, err) defer func() { require.NoError(t, manager.Close(nil)) @@ -202,9 +208,11 @@ func TestInboundPortDNAT(t *testing.T) { } func TestInboundPortDNATNegative(t *testing.T) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, iface.DefaultMTU) + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(t, err) defer func() { require.NoError(t, manager.Close(nil)) diff --git a/client/firewall/uspfilter/peer_acl_bench_test.go b/client/firewall/uspfilter/peer_acl_bench_test.go new file mode 100644 index 000000000..bcb0ca5c2 --- /dev/null +++ b/client/firewall/uspfilter/peer_acl_bench_test.go @@ -0,0 +1,333 @@ +//go:build uspbench + +package uspfilter + +import ( + "fmt" + "io" + "math/rand" + "net" + "net/netip" + "runtime" + "testing" + + "github.com/google/gopacket" + "github.com/google/gopacket/layers" + log "github.com/sirupsen/logrus" + "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/device" + "github.com/netbirdio/netbird/client/iface/wgaddr" +) + +// BenchmarkPeerACLMatch measures the per-packet cost of the peer ACL +// matcher (peerACLsBlock) across realistic shapes: M distinct policy +// rules, each with K source peers in its set. +// +// With the reverse-source index, miss cost is independent of M and +// hit cost grows only with the number of rules touching a single +// srcIP, not with total rule count. +func BenchmarkPeerACLMatch(b *testing.B) { + shapes := []struct{ M, K int }{ + {1, 100}, {10, 100}, {50, 100}, {100, 100}, {100, 1000}, + } + families := []struct { + name string + v6 bool + }{{"v4", false}, {"v6", true}} + + for _, fam := range families { + for _, s := range shapes { + b.Run(fmt.Sprintf("%s/M=%d/K=%d/hit", fam.name, s.M, s.K), func(b *testing.B) { + runPeerACLBench(b, s.M, s.K, true, fam.v6) + }) + b.Run(fmt.Sprintf("%s/M=%d/K=%d/miss", fam.name, s.M, s.K), func(b *testing.B) { + runPeerACLBench(b, s.M, s.K, false, fam.v6) + }) + } + } +} + +func runPeerACLBench(b *testing.B, m, k int, hit, v6 bool) { + log.SetOutput(io.Discard) // keep manager logs out of the benchmark output + + // Miss packets are dropped, so they always traverse the full peer + // ACL matcher (every bucket) without short-circuiting and without + // touching conntrack. Disable conntrack for the miss case so it + // measures the matcher, not established-state lookups. The hit case + // keeps conntrack on: an accepted packet reaches trackInbound, which + // needs the trackers conntrack creates. + if !hit { + b.Setenv("NB_DISABLE_CONNTRACK", "1") + } + + bits := 32 + genPkt := generatePacket + addrs := uniqueAddrs + if v6 { + bits = 128 + genPkt = generatePacket6 + addrs = uniqueAddrs6 + } + + // dstIP must be a local IP so filterInbound takes the local-traffic + // path (handleLocalTraffic → peerACLsBlock) we are measuring; an + // address the manager doesn't own would be treated as routed and + // short-circuit before the peer matcher. + dstIP := addrs(1, 2)[0] + mockAddr := wgaddr.Address{IP: dstIP, Network: netip.PrefixFrom(dstIP, bits)} + if v6 { + // The local-IP manager needs a valid v4 address too; expose the v6 + // dst as the interface's IPv6 so IsLocalIP recognizes it. + mockAddr = wgaddr.Address{ + IP: netip.MustParseAddr("100.64.0.1"), + Network: netip.MustParsePrefix("100.64.0.0/16"), + IPv6: dstIP, + IPv6Net: netip.PrefixFrom(dstIP, bits), + } + } + manager, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + AddressFunc: func() wgaddr.Address { return mockAddr }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) + require.NoError(b, err) + b.Cleanup(func() { require.NoError(b, manager.Close(nil)) }) + + // Generate M policies × K source peers, all distinct. + all := addrs(m*k, 1) + for i := 0; i < m; i++ { + sources := make([]netip.Prefix, k) + for j, a := range all[i*k : (i+1)*k] { + sources[j] = netip.PrefixFrom(a, bits) + } + _, err := manager.AddFilterRule( + nil, sources, fw.Network{}, fw.ProtocolTCP, nil, + &fw.Port{Values: []uint16{uint16(80 + i)}}, + fw.ActionAccept) + require.NoError(b, err) + } + + // Hit: cycle through real sources, picking the matching policy's port. + // Miss: a source from a disjoint range, port 80 (matches no policy). + var pktFn func(i int) []byte + if hit { + pktFn = func(i int) []byte { + policy := i % m + src := all[policy*k+(i%k)] + return genPkt(b, src.AsSlice(), dstIP.AsSlice(), + uint16(1024+i%60000), uint16(80+policy), layers.IPProtocolTCP) + } + } else { + miss := addrs(4096, 99) + pktFn = func(i int) []byte { + return genPkt(b, miss[i%len(miss)].AsSlice(), dstIP.AsSlice(), + uint16(1024+i%60000), 80, layers.IPProtocolTCP) + } + } + + // Pre-build a pool to avoid allocations dominating the measurement. + pool := make([][]byte, 1024) + for i := range pool { + pool[i] = pktFn(i) + } + + // Confirm the matcher is actually exercised: a hit packet must be + // allowed and a miss packet dropped. Without this the benchmark + // could silently time the routed early-return instead. + require.Equal(b, !hit, manager.filterInbound(pool[0], 0), + "benchmark must reach the peer ACL matcher") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + manager.filterInbound(pool[i%len(pool)], 0) + } +} + +// BenchmarkPeerACLIndexMemory reports the resident memory cost of +// the source-keyed index across realistic deployment shapes. Two +// dimensions matter: (M, K), the number of policies × peers-per-policy, +// and overlap, the fraction of peers shared between policies. +// +// The output uses ReportMetric("bytes/rule") so the cost can be +// compared across shapes directly. Total bytes = bytes/rule * M. +func BenchmarkPeerACLIndexMemory(b *testing.B) { + cases := []struct { + name string + M, K int + overlapFrac float64 // 0 = disjoint per-policy sources, 1 = all share the same pool + }{ + {"M=10/K=100/disjoint", 10, 100, 0}, + {"M=100/K=100/disjoint", 100, 100, 0}, + {"M=100/K=1000/disjoint", 100, 1000, 0}, + {"M=100/K=1000/overlap=0.5", 100, 1000, 0.5}, + {"M=100/K=1000/overlap=1.0", 100, 1000, 1.0}, + {"M=1000/K=100/overlap=1.0", 1000, 100, 1.0}, + } + + for _, c := range cases { + b.Run(c.name, func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + mgr, err := Create(Config{ + IFace: &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, + FlowLogger: flowLogger, MTU: iface.DefaultMTU}) + require.NoError(b, err) + + populateIndexedRules(b, mgr, c.M, c.K, c.overlapFrac) + + runtime.GC() + var ms runtime.MemStats + runtime.ReadMemStats(&ms) + before := ms.HeapAlloc + + // Drop the manager's external roots so we can isolate + // the index cost. We hold the manager itself live; the + // index is what we measure on the second pass. + mgr.incomingAcceptIndex.reset() + mgr.incomingDenyIndex.reset() + mgr.incomingAcceptRules = mgr.incomingAcceptRules[:0] + mgr.incomingDenyRules = mgr.incomingDenyRules[:0] + runtime.GC() + runtime.ReadMemStats(&ms) + after := ms.HeapAlloc + + delta := int64(before) - int64(after) + if delta < 0 { + delta = 0 + } + b.ReportMetric(float64(delta)/float64(c.M), "bytes/rule") + b.ReportMetric(float64(delta), "bytes/total") + + require.NoError(b, mgr.Close(nil)) + } + }) + } +} + +func populateIndexedRules(b *testing.B, mgr *Manager, m, k int, overlapFrac float64) { + b.Helper() + pool := uniqueAddrs(k+m*k, 1) // big enough universe + sharedLen := int(float64(k) * overlapFrac) + if sharedLen > k { + sharedLen = k + } + shared := pool[:sharedLen] + uniquePool := pool[sharedLen:] + + for i := 0; i < m; i++ { + sources := make([]netip.Prefix, 0, k) + for _, a := range shared { + sources = append(sources, netip.PrefixFrom(a, 32)) + } + // each policy gets (k-sharedLen) addresses unique to it from the unique pool + unique := uniquePool[i*(k-sharedLen) : (i+1)*(k-sharedLen)] + for _, a := range unique { + sources = append(sources, netip.PrefixFrom(a, 32)) + } + _, err := mgr.AddFilterRule( + nil, sources, fw.Network{}, fw.ProtocolTCP, nil, + &fw.Port{Values: []uint16{uint16(80 + i)}}, + fw.ActionAccept) + require.NoError(b, err) + } +} + +// uniqueAddrs returns n distinct addrs. Seeds 1, 2 are used for +// policy sources / dst; seed 99 puts misses in 10/8. +func uniqueAddrs(n int, seed int64) []netip.Addr { + out := make([]netip.Addr, 0, n) + seen := make(map[netip.Addr]struct{}, n) + r := rand.New(rand.NewSource(seed)) + miss := seed == 99 + for len(out) < n { + var b [4]byte + if miss { + b[0] = 10 + b[1] = byte(r.Intn(256)) + } else { + b[0] = 100 + b[1] = byte(64 + r.Intn(63)) + } + b[2] = byte(r.Intn(256)) + b[3] = byte(1 + r.Intn(254)) + a := netip.AddrFrom4(b) + if _, ok := seen[a]; ok { + continue + } + seen[a] = struct{}{} + out = append(out, a) + } + return out +} + +// uniqueAddrs6 mirrors uniqueAddrs for IPv6: sources come from the ULA +// range fd00::/8, the miss set (seed 99) from 2001:db8::/32 so it is +// disjoint from any source. +func uniqueAddrs6(n int, seed int64) []netip.Addr { + out := make([]netip.Addr, 0, n) + seen := make(map[netip.Addr]struct{}, n) + r := rand.New(rand.NewSource(seed)) + miss := seed == 99 + for len(out) < n { + var b [16]byte + if miss { + b[0], b[1], b[2], b[3] = 0x20, 0x01, 0x0d, 0xb8 + } else { + b[0] = 0xfd + } + for x := 8; x < 16; x++ { + b[x] = byte(r.Intn(256)) + } + a := netip.AddrFrom16(b) + if _, ok := seen[a]; ok { + continue + } + seen[a] = struct{}{} + out = append(out, a) + } + return out +} + +// generatePacket6 builds an IPv6 TCP/UDP packet, mirroring +// generatePacket for the v4 case. +func generatePacket6(b *testing.B, srcIP, dstIP net.IP, srcPort, dstPort uint16, protocol layers.IPProtocol) []byte { + b.Helper() + + ipv6 := &layers.IPv6{ + Version: 6, + HopLimit: 64, + NextHeader: protocol, + SrcIP: srcIP, + DstIP: dstIP, + } + + var transportLayer gopacket.SerializableLayer + switch protocol { + case layers.IPProtocolTCP: + tcp := &layers.TCP{ + SrcPort: layers.TCPPort(srcPort), + DstPort: layers.TCPPort(dstPort), + SYN: true, + } + require.NoError(b, tcp.SetNetworkLayerForChecksum(ipv6)) + transportLayer = tcp + case layers.IPProtocolUDP: + udp := &layers.UDP{ + SrcPort: layers.UDPPort(srcPort), + DstPort: layers.UDPPort(dstPort), + } + require.NoError(b, udp.SetNetworkLayerForChecksum(ipv6)) + transportLayer = udp + } + + buf := gopacket.NewSerializeBuffer() + opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true} + require.NoError(b, gopacket.SerializeLayers(buf, opts, ipv6, transportLayer, gopacket.Payload("test"))) + return buf.Bytes() +} diff --git a/client/firewall/uspfilter/peer_acl_dedup_test.go b/client/firewall/uspfilter/peer_acl_dedup_test.go new file mode 100644 index 000000000..696766d80 --- /dev/null +++ b/client/firewall/uspfilter/peer_acl_dedup_test.go @@ -0,0 +1,150 @@ +package uspfilter + +import ( + "net" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + fw "github.com/netbirdio/netbird/client/firewall/manager" + nbiface "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/device" +) + +func newTestManager(t *testing.T) *Manager { + t.Helper() + ifaceMock := &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + } + m, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) + require.NoError(t, err, "create manager") + t.Cleanup(func() { require.NoError(t, m.Close(nil)) }) + return m +} + +// TestAddPeerFiltering_DeduplicatesIdenticalRules verifies that adding +// the same peer rule twice does not create two backing rules. The acl +// manager keys its own cache, but the firewall backend must be +// idempotent on its own so a double-apply cannot leak rules, matching +// the route path and the kernel backends. +func TestAddPeerFiltering_DeduplicatesIdenticalRules(t *testing.T) { + m := newTestManager(t) + + ip := net.ParseIP("192.168.1.1") + proto := fw.ProtocolTCP + port := &fw.Port{Values: []uint16{80}} + action := fw.ActionDrop + + first, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, port, action) + require.NoError(t, err, "first add") + + second, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, port, action) + require.NoError(t, err, "second add") + + assert.Equal(t, first.ID(), second.ID(), "duplicate add should return the same rule id") + assert.Len(t, m.incomingDenyRules, 1, "duplicate add must not create a second backing rule") +} + +// TestDeletePeerFiltering_NoRefcountSingleDeleteRemoves locks the +// backend's owner accounting for the same-owner case: a content key +// installed twice by the same owner registers one owner claim, so the +// first DeleteFilterRule removes the rule. Owner counting only kicks +// in for distinct management rule IDs (see the peer owner tests); the +// acl manager keys its tracking per (policy, content) and deletes once +// per key, so adds and deletes stay balanced. +func TestDeletePeerFiltering_NoRefcountSingleDeleteRemoves(t *testing.T) { + m := newTestManager(t) + + ip := net.ParseIP("192.168.1.1") + proto := fw.ProtocolTCP + port := &fw.Port{Values: []uint16{80}} + action := fw.ActionDrop + + first, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, port, action) + require.NoError(t, err, "first add") + + second, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, port, action) + require.NoError(t, err, "second add") + require.Equal(t, first.ID(), second.ID(), "dedup to one rule") + require.Len(t, m.incomingDenyRules, 1, "still one backing rule after duplicate add") + + require.NoError(t, m.DeleteFilterRule(first), "delete once") + assert.Empty(t, m.incomingDenyRules, "single delete removes the backing rule (no refcount)") + assert.NotContains(t, m.peerRulesMap, first.ID(), "dedup map entry cleared") +} + +// TestAddPeerFiltering_DeterministicID verifies the peer rule id is a +// content hash, not a random UUID: identical inputs produce the same id +// across independent managers. A random id breaks caller-side dedup. +func TestAddPeerFiltering_DeterministicID(t *testing.T) { + ip := net.ParseIP("10.0.0.5") + proto := fw.ProtocolUDP + port := &fw.Port{Values: []uint16{53}} + action := fw.ActionAccept + + m1 := newTestManager(t) + r1, err := m1.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, port, action) + require.NoError(t, err) + + m2 := newTestManager(t) + r2, err := m2.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, port, action) + require.NoError(t, err) + + assert.Equal(t, r1.ID(), r2.ID(), "same inputs must produce the same rule id") +} + +// TestAddPeerFiltering_DistinctRulesNotDeduped verifies that rules +// differing only by port are kept separate. +func TestAddPeerFiltering_DistinctRulesNotDeduped(t *testing.T) { + m := newTestManager(t) + + ip := net.ParseIP("192.168.1.1") + proto := fw.ProtocolTCP + action := fw.ActionAccept + + r80, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, &fw.Port{Values: []uint16{80}}, action) + require.NoError(t, err) + + r443, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, &fw.Port{Values: []uint16{443}}, action) + require.NoError(t, err) + + assert.NotEqual(t, r80.ID(), r443.ID(), "different ports must produce different rule ids") + assert.Len(t, m.incomingAcceptRules, 2, "distinct rules must both be stored") +} + +// TestAddPeerFiltering_SourceVsDestPortNotDeduped verifies that a rule +// matching on source port and one matching on destination port for the +// same selector do not collide: the port lands in a different slot, so +// the content key must differ. +func TestAddPeerFiltering_SourceVsDestPortNotDeduped(t *testing.T) { + m := newTestManager(t) + + ip := net.ParseIP("192.168.1.1") + proto := fw.ProtocolTCP + port := &fw.Port{Values: []uint16{80}} + action := fw.ActionAccept + + dPortRule, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, port, action) + require.NoError(t, err) + + sPortRule, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, port, nil, action) + require.NoError(t, err) + + assert.NotEqual(t, dPortRule.ID(), sPortRule.ID(), "source-port and dest-port matches must produce different rule ids") +} + +// TestAddFilterRule_EmptySourcesRejected verifies that an empty source +// list is rejected rather than treated as "match any". "Match any" must +// be an explicit /0, so a zeroed list can never silently widen a rule to +// every source. +func TestAddFilterRule_EmptySourcesRejected(t *testing.T) { + m := newTestManager(t) + + proto := fw.ProtocolTCP + port := &fw.Port{Values: []uint16{80}} + + _, err := m.AddFilterRule(nil, nil, fw.Network{}, proto, nil, port, fw.ActionAccept) + require.ErrorIs(t, err, fw.ErrNoSources, "empty sources must be rejected") + assert.Empty(t, m.incomingAcceptRules, "no rule should be stored for empty sources") +} diff --git a/client/firewall/uspfilter/peer_acl_ipv6_test.go b/client/firewall/uspfilter/peer_acl_ipv6_test.go new file mode 100644 index 000000000..282b44ebb --- /dev/null +++ b/client/firewall/uspfilter/peer_acl_ipv6_test.go @@ -0,0 +1,105 @@ +package uspfilter + +import ( + "net" + "net/netip" + "testing" + + "github.com/google/gopacket" + "github.com/google/gopacket/layers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + fw "github.com/netbirdio/netbird/client/firewall/manager" + nbiface "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/device" + "github.com/netbirdio/netbird/client/iface/wgaddr" +) + +func newV6TestManager(t *testing.T, localV6 string) *Manager { + t.Helper() + ifaceMock := &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + AddressFunc: func() wgaddr.Address { + return wgaddr.Address{ + IP: netip.MustParseAddr("100.10.0.100"), + Network: netip.MustParsePrefix("100.10.0.0/16"), + IPv6: netip.MustParseAddr(localV6), + IPv6Net: netip.MustParsePrefix("fd00::/64"), + } + }, + } + m, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: nbiface.DefaultMTU}) + require.NoError(t, err, "create manager") + t.Cleanup(func() { require.NoError(t, m.Close(nil)) }) + return m +} + +func v6UDPPacket(t *testing.T, src, dst string, dstPort uint16) []byte { + t.Helper() + ip6 := &layers.IPv6{ + Version: 6, + HopLimit: 64, + NextHeader: layers.IPProtocolUDP, + SrcIP: net.ParseIP(src), + DstIP: net.ParseIP(dst), + } + udp := &layers.UDP{SrcPort: 51334, DstPort: layers.UDPPort(dstPort)} + require.NoError(t, udp.SetNetworkLayerForChecksum(ip6)) + + buf := gopacket.NewSerializeBuffer() + opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true} + require.NoError(t, gopacket.SerializeLayers(buf, opts, ip6, udp, gopacket.Payload("test"))) + return buf.Bytes() +} + +// TestPeerACL_IPv6HostRule verifies the source index resolves /128 v6 +// rules: a matching v6 source is accepted, a non-matching one is +// denied by the default. This is the end-to-end proof that the index +// is not v4-only. +func TestPeerACL_IPv6HostRule(t *testing.T) { + m := newV6TestManager(t, "fd00::100") + + src := net.ParseIP("fd00::1") + _, err := m.AddFilterRule(nil, pfx(src), fw.Network{}, fw.ProtocolUDP, nil, &fw.Port{Values: []uint16{53}}, fw.ActionAccept) + require.NoError(t, err, "add v6 accept rule") + + require.False(t, m.filterInbound(v6UDPPacket(t, "fd00::1", "fd00::100", 53), 0), + "v6 packet from the allowed /128 source must be accepted") + require.True(t, m.filterInbound(v6UDPPacket(t, "fd00::2", "fd00::100", 53), 0), + "v6 packet from an unlisted source must be denied by default") +} + +// TestPeerACL_IPv6IndexBuckets verifies that v6 sources land in the +// right index bucket: a /128 in bySource keyed by its address, and +// coarser prefixes (including ::/0) in the nonHost slice. +func TestPeerACL_IPv6IndexBuckets(t *testing.T) { + m := newV6TestManager(t, "fd00::100") + port := &fw.Port{Values: []uint16{53}} + + host := netip.MustParseAddr("fd00::1") + _, err := m.AddFilterRule(nil, []netip.Prefix{netip.PrefixFrom(host, 128)}, fw.Network{}, fw.ProtocolUDP, nil, port, fw.ActionAccept) + require.NoError(t, err) + assert.Contains(t, m.incomingAcceptIndex.bySource, host, "/128 v6 source must be indexed by address") + + _, err = m.AddFilterRule(nil, []netip.Prefix{netip.MustParsePrefix("fd00:dead::/64")}, fw.Network{}, fw.ProtocolUDP, nil, port, fw.ActionAccept) + require.NoError(t, err) + require.Len(t, m.incomingAcceptIndex.nonHost, 1, "coarser v6 prefix must land in nonHost") + + _, err = m.AddFilterRule(nil, []netip.Prefix{netip.MustParsePrefix("::/0")}, fw.Network{}, fw.ProtocolUDP, nil, port, fw.ActionAccept) + require.NoError(t, err) + require.Len(t, m.incomingAcceptIndex.nonHost, 2, "::/0 source must also land in nonHost") +} + +// TestPeerACL_IPv4MappedSourceNormalized verifies a v4-mapped v6 +// source prefix is normalized to v4 so a plain v4 packet matches it. +func TestPeerACL_IPv4MappedSourceNormalized(t *testing.T) { + m := newTestManager(t) + + mapped := netip.MustParseAddr("::ffff:192.168.1.1") + _, err := m.AddFilterRule(nil, []netip.Prefix{netip.PrefixFrom(mapped, mapped.BitLen())}, fw.Network{}, fw.ProtocolUDP, nil, &fw.Port{Values: []uint16{53}}, fw.ActionAccept) + require.NoError(t, err) + + v4 := netip.MustParseAddr("192.168.1.1") + assert.Contains(t, m.incomingAcceptIndex.bySource, v4, "v4-mapped v6 source must be indexed as plain v4") +} diff --git a/client/firewall/uspfilter/peer_family_scope_test.go b/client/firewall/uspfilter/peer_family_scope_test.go new file mode 100644 index 000000000..1cf3498fb --- /dev/null +++ b/client/firewall/uspfilter/peer_family_scope_test.go @@ -0,0 +1,104 @@ +package uspfilter + +import ( + "net" + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + fw "github.com/netbirdio/netbird/client/firewall/manager" +) + +// peerACLCheck decodes the packet and runs it through the peer ACLs, +// returning the attributed management rule id and the drop verdict. +func peerACLCheck(t *testing.T, m *Manager, packet []byte) ([]byte, bool) { + t.Helper() + d := m.decoders.Get().(*decoder) + defer m.decoders.Put(d) + require.NoError(t, d.decodePacket(packet)) + src, _ := m.extractIPs(d) + return m.peerACLsBlock(src, d, packet) +} + +// TestPeerACL_MultiValuePortMatchesEachListedPort guards the multi-value +// port path: a rule listing several discrete destination ports must +// match a packet to each listed port and drop one that is not listed. +// Management currently splits a multi-port policy into one rule per port +// (and the wire format carries a single port), so this list shape is not +// emitted today; the test locks correct matching in case that changes. +func TestPeerACL_MultiValuePortMatchesEachListedPort(t *testing.T) { + m := newTestManager(t) + + src := net.ParseIP("192.168.1.1") + ports := &fw.Port{Values: []uint16{80, 443}} + _, err := m.AddFilterRule(nil, pfx(src), fw.Network{}, fw.ProtocolTCP, nil, ports, fw.ActionAccept) + require.NoError(t, err, "add multi-value port rule") + + for _, p := range []uint16{80, 443} { + _, blocked := peerACLCheck(t, m, createTestPacket(t, "192.168.1.1", "10.0.0.2", fw.ProtocolTCP, 12345, p)) + assert.False(t, blocked, "packet to listed port %d must match the rule", p) + } + + _, blocked := peerACLCheck(t, m, createTestPacket(t, "192.168.1.1", "10.0.0.2", fw.ProtocolTCP, 12345, 8080)) + assert.True(t, blocked, "packet to a port not in the list must not match the rule") +} + +// TestPeerACL_MatchAnyIsFamilyScoped verifies that a /0 source matches +// only packets of its own family: 0.0.0.0/0 must not match IPv6 packets +// and ::/0 must not match IPv4 packets, matching kernel backend +// semantics. +func TestPeerACL_MatchAnyIsFamilyScoped(t *testing.T) { + m := newTestManager(t) + + v4Packet := createTestPacket(t, "10.0.0.1", "10.0.0.2", fw.ProtocolUDP, 12345, 53) + v6Packet := v6UDPPacket(t, "fd00::1", "fd00::100", 53) + + v4Any := []netip.Prefix{netip.PrefixFrom(netip.IPv4Unspecified(), 0)} + rule, err := m.AddFilterRule(nil, v4Any, fw.Network{}, fw.ProtocolALL, nil, nil, fw.ActionAccept) + require.NoError(t, err, "add v4 /0 rule") + + _, blocked := peerACLCheck(t, m, v4Packet) + assert.False(t, blocked, "0.0.0.0/0 must match IPv4 packets") + _, blocked = peerACLCheck(t, m, v6Packet) + assert.True(t, blocked, "0.0.0.0/0 must not match IPv6 packets") + + require.NoError(t, m.DeleteFilterRule(rule)) + + v6Any := []netip.Prefix{netip.PrefixFrom(netip.IPv6Unspecified(), 0)} + _, err = m.AddFilterRule(nil, v6Any, fw.Network{}, fw.ProtocolALL, nil, nil, fw.ActionAccept) + require.NoError(t, err, "add v6 /0 rule") + + _, blocked = peerACLCheck(t, m, v6Packet) + assert.False(t, blocked, "::/0 must match IPv6 packets") + _, blocked = peerACLCheck(t, m, v4Packet) + assert.True(t, blocked, "::/0 must not match IPv4 packets") +} + +// TestRouteACL_MixedFamilyZeroSourcesStayFamilySafe verifies the route +// path keeps per-prefix family matching when a single rule carries both +// 0.0.0.0/0 and ::/0 sources, as blockInvalidRouted does. +func TestRouteACL_MixedFamilyZeroSourcesStayFamilySafe(t *testing.T) { + m := newTestManager(t) + + sources := []netip.Prefix{ + netip.PrefixFrom(netip.IPv4Unspecified(), 0), + netip.PrefixFrom(netip.IPv6Unspecified(), 0), + } + + _, err := m.AddFilterRule(nil, sources, fw.Network{Prefix: netip.MustParsePrefix("10.0.0.0/24")}, + fw.ProtocolALL, nil, nil, fw.ActionAccept) + require.NoError(t, err) + _, err = m.AddFilterRule(nil, sources, fw.Network{Prefix: netip.MustParsePrefix("fd00:1::/64")}, + fw.ProtocolALL, nil, nil, fw.ActionAccept) + require.NoError(t, err) + + v4Src := netip.MustParseAddr("192.168.1.1") + v6Src := netip.MustParseAddr("fd00::1") + + _, pass := m.routeACLsPass(v4Src, netip.MustParseAddr("10.0.0.5"), 255, 0, 0) + assert.True(t, pass, "v4 source must match the v4 destination rule via 0.0.0.0/0") + _, pass = m.routeACLsPass(v6Src, netip.MustParseAddr("fd00:1::5"), 255, 0, 0) + assert.True(t, pass, "v6 source must match the v6 destination rule via ::/0") +} diff --git a/client/firewall/uspfilter/peer_index.go b/client/firewall/uspfilter/peer_index.go new file mode 100644 index 000000000..552ffd2d7 --- /dev/null +++ b/client/firewall/uspfilter/peer_index.go @@ -0,0 +1,140 @@ +package uspfilter + +import ( + "net/netip" + "slices" + + "github.com/google/gopacket" + "github.com/google/gopacket/layers" + + firewall "github.com/netbirdio/netbird/client/firewall/manager" +) + +// peerRuleIndex is the source-side dispatcher consulted on the packet +// hot path. It splits rules into two buckets by the shape of their +// source list: +// +// - bySource: every source is a host prefix (/32 for v4, /128 for +// v6). Keyed by the concrete source address, so a hit guarantees +// the source filter passes and the matcher goes straight to +// proto/port checks. This is the common case for peer ACLs. +// - nonHost: any source list with a prefix coarser than a host, +// including a /0 "match any". Walked linearly with a per-rule +// Contains() check. Expected small or empty for typical peer ACLs. +// +// Maintained incrementally by add/remove, never rebuilt. +type peerRuleIndex struct { + bySource map[netip.Addr][]*PeerRule + nonHost []*PeerRule +} + +func (i *peerRuleIndex) add(r *PeerRule) { + if hasNonHostSource(r) { + i.nonHost = append(i.nonHost, r) + return + } + if i.bySource == nil { + i.bySource = make(map[netip.Addr][]*PeerRule) + } + for a := range r.sourceAddrs { + i.bySource[a] = append(i.bySource[a], r) + } +} + +func (i *peerRuleIndex) remove(r *PeerRule) { + if hasNonHostSource(r) { + i.nonHost = slices.DeleteFunc(i.nonHost, eqRule(r)) + return + } + if i.bySource == nil { + return + } + for a := range r.sourceAddrs { + entries := slices.DeleteFunc(i.bySource[a], eqRule(r)) + if len(entries) == 0 { + delete(i.bySource, a) + } else { + i.bySource[a] = entries + } + } +} + +func (i *peerRuleIndex) reset() { + i.bySource = nil + i.nonHost = i.nonHost[:0] +} + +// match returns the first rule matching src and the decoded packet. +// Host rules are found by direct map lookup; nonHost rules run a +// per-rule source Contains() check. Containment is family-scoped, so +// a /0 source matches every address of its own family only (0.0.0.0/0 +// never matches v6 sources and ::/0 never matches v4). Within either +// bucket the matcher runs the proto/port filter. +func (i *peerRuleIndex) match(src netip.Addr, d *decoder) ([]byte, bool, bool) { + payloadLayer := d.decoded[1] + + for _, rule := range i.bySource[src] { + if id, drop, ok := matchProto(rule, d, payloadLayer); ok { + return id, drop, true + } + } + for _, rule := range i.nonHost { + if !prefixesContain(rule.sources, src) { + continue + } + if id, drop, ok := matchProto(rule, d, payloadLayer); ok { + return id, drop, true + } + } + return nil, false, false +} + +func eqRule(target *PeerRule) func(*PeerRule) bool { + return func(p *PeerRule) bool { return p == target } +} + +// hasNonHostSource reports whether the rule has any source prefix +// that is not a single host address. Called only at add/remove time, +// not on the packet path. +func hasNonHostSource(r *PeerRule) bool { + for _, p := range r.sources { + if p.Bits() != p.Addr().BitLen() { + return true + } + } + return false +} + +// matchProto applies the proto/port half of a rule against the +// decoded packet. Source matching is the caller's responsibility. +func matchProto(rule *PeerRule, d *decoder, payloadLayer gopacket.LayerType) ([]byte, bool, bool) { + drop := rule.action == firewall.ActionDrop + if rule.protoLayer == layerTypeAll { + return rule.mgmtId, drop, true + } + if !protoLayerMatches(rule.protoLayer, payloadLayer) { + return nil, false, false + } + switch payloadLayer { + case layers.LayerTypeTCP: + if portsMatch(rule.srcPort, uint16(d.tcp.SrcPort)) && portsMatch(rule.dstPort, uint16(d.tcp.DstPort)) { + return rule.mgmtId, drop, true + } + case layers.LayerTypeUDP: + if portsMatch(rule.srcPort, uint16(d.udp.SrcPort)) && portsMatch(rule.dstPort, uint16(d.udp.DstPort)) { + return rule.mgmtId, drop, true + } + case layers.LayerTypeICMPv4, layers.LayerTypeICMPv6: + return rule.mgmtId, drop, true + } + return nil, false, false +} + +func prefixesContain(sources []netip.Prefix, src netip.Addr) bool { + for _, p := range sources { + if p.Contains(src) { + return true + } + } + return false +} diff --git a/client/firewall/uspfilter/rule.go b/client/firewall/uspfilter/rule.go index 08d68a78e..6d73b19ac 100644 --- a/client/firewall/uspfilter/rule.go +++ b/client/firewall/uspfilter/rule.go @@ -10,24 +10,43 @@ import ( // PeerRule to handle management of rules type PeerRule struct { - id string - mgmtId []byte - ip netip.Addr - ipLayer gopacket.LayerType - matchByIP bool - protoLayer gopacket.LayerType - sPort *firewall.Port - dPort *firewall.Port - drop bool + id firewall.RuleID + mgmtId []byte + // sources is the canonical list of source prefixes this rule + // matches against. + sources []netip.Prefix + // sourceAddrs is a fast-path membership set for host-prefix + // sources (/32 v4, /128 v6). Populated alongside sources; + // consulted before falling back to prefix scan. + sourceAddrs map[netip.Addr]struct{} + protoLayer gopacket.LayerType + srcPort *firewall.Port + dstPort *firewall.Port + action firewall.Action +} + +// matchesSource reports whether the given source address is covered +// by this rule's source list. Prefix containment is family-scoped, so +// a /0 source matches every address of its own family only. +func (r *PeerRule) matchesSource(src netip.Addr) bool { + if _, ok := r.sourceAddrs[src]; ok { + return true + } + for _, p := range r.sources { + if p.Contains(src) { + return true + } + } + return false } // ID returns the rule id -func (r *PeerRule) ID() string { +func (r *PeerRule) ID() firewall.RuleID { return r.id } type RouteRule struct { - id string + id firewall.RuleID mgmtId []byte sources []netip.Prefix dstSet firewall.Set @@ -39,6 +58,6 @@ type RouteRule struct { } // ID returns the rule id -func (r *RouteRule) ID() string { +func (r *RouteRule) ID() firewall.RuleID { return r.id } diff --git a/client/firewall/uspfilter/testhelpers_test.go b/client/firewall/uspfilter/testhelpers_test.go new file mode 100644 index 000000000..a0760f5ba --- /dev/null +++ b/client/firewall/uspfilter/testhelpers_test.go @@ -0,0 +1,50 @@ +package uspfilter + +import ( + "net" + "net/netip" + + firewall "github.com/netbirdio/netbird/client/firewall/manager" +) + +// countRulesForAddr reports how many rules in the given slice match +// the supplied source address. +func countRulesForAddr(rules peerRules, src netip.Addr) int { + n := 0 + for _, r := range rules { + if r.matchesSource(src) { + n++ + } + } + return n +} + +// findRuleByID returns true if the rules slice contains a rule with +// the given id whose source set covers src. +func findRuleByID(rules peerRules, src netip.Addr, id firewall.RuleID) bool { + for _, r := range rules { + if r.id == id && r.matchesSource(src) { + return true + } + } + return false +} + +// pfx converts a single net.IP into the []netip.Prefix form +// AddFilterRule expects. A nil or unspecified address becomes a /0 +// ("match any") prefix in the matching family; any other address +// becomes its /32 (or /128) host prefix. +func pfx(ip net.IP) []netip.Prefix { + if ip == nil { + return []netip.Prefix{netip.PrefixFrom(netip.IPv4Unspecified(), 0)} + } + if ip.IsUnspecified() { + if ip.To4() != nil { + return []netip.Prefix{netip.PrefixFrom(netip.IPv4Unspecified(), 0)} + } + return []netip.Prefix{netip.PrefixFrom(netip.IPv6Unspecified(), 0)} + } + a, _ := netip.AddrFromSlice(ip) + a = a.Unmap() + return []netip.Prefix{netip.PrefixFrom(a, a.BitLen())} +} diff --git a/client/firewall/uspfilter/tracer.go b/client/firewall/uspfilter/tracer.go index 696489e95..3c081314c 100644 --- a/client/firewall/uspfilter/tracer.go +++ b/client/firewall/uspfilter/tracer.go @@ -285,6 +285,14 @@ func (m *Manager) TracePacket(packetData []byte, direction fw.RuleDirection) *Pa trace.SourceIP = srcIP trace.DestinationIP = dstIP + // A fragment or otherwise truncated packet has no transport layer. + // The inbound datapath drops these via isValidPacket; the tracer must + // guard explicitly since every downstream stage reads d.decoded[1]. + if len(d.decoded) < 2 { + trace.AddResult(StageReceived, "Packet has no transport layer (fragment or unsupported protocol)", false) + return trace + } + // Determine protocol and ports switch d.decoded[1] { case layers.LayerTypeTCP: diff --git a/client/firewall/uspfilter/tracer_test.go b/client/firewall/uspfilter/tracer_test.go index 657f96fc0..27b5e3f9e 100644 --- a/client/firewall/uspfilter/tracer_test.go +++ b/client/firewall/uspfilter/tracer_test.go @@ -45,7 +45,7 @@ func TestTracePacket(t *testing.T) { }, } - m, err := Create(ifaceMock, false, flowLogger, iface.DefaultMTU) + m, err := Create(Config{IFace: ifaceMock, FlowLogger: flowLogger, MTU: iface.DefaultMTU}) require.NoError(t, err) if !statefulMode { @@ -97,7 +97,7 @@ func TestTracePacket(t *testing.T) { proto := fw.ProtocolTCP port := &fw.Port{Values: []uint16{80}} action := fw.ActionAccept - _, err := m.AddPeerFiltering(nil, ip, proto, nil, port, action, "") + _, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, port, action) require.NoError(t, err) }, packetBuilder: func() *PacketBuilder { @@ -121,7 +121,7 @@ func TestTracePacket(t *testing.T) { proto := fw.ProtocolTCP port := &fw.Port{Values: []uint16{80}} action := fw.ActionDrop - _, err := m.AddPeerFiltering(nil, ip, proto, nil, port, action, "") + _, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, port, action) require.NoError(t, err) }, packetBuilder: func() *PacketBuilder { @@ -150,7 +150,7 @@ func TestTracePacket(t *testing.T) { proto := fw.ProtocolTCP port := &fw.Port{Values: []uint16{80}} action := fw.ActionAccept - _, err := m.AddPeerFiltering(nil, ip, proto, nil, port, action, "") + _, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, port, action) require.NoError(t, err) }, packetBuilder: func() *PacketBuilder { @@ -178,7 +178,7 @@ func TestTracePacket(t *testing.T) { proto := fw.ProtocolTCP port := &fw.Port{Values: []uint16{80}} action := fw.ActionAccept - _, err := m.AddPeerFiltering(nil, ip, proto, nil, port, action, "") + _, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, port, action) require.NoError(t, err) }, packetBuilder: func() *PacketBuilder { @@ -205,7 +205,7 @@ func TestTracePacket(t *testing.T) { src := netip.PrefixFrom(netip.AddrFrom4([4]byte{1, 1, 1, 1}), 32) dst := netip.PrefixFrom(netip.AddrFrom4([4]byte{192, 168, 17, 2}), 32) - _, err := m.AddRouteFiltering(nil, []netip.Prefix{src}, fw.Network{Prefix: dst}, fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionAccept) + _, err := m.AddFilterRule(nil, []netip.Prefix{src}, fw.Network{Prefix: dst}, fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionAccept) require.NoError(t, err) }, packetBuilder: func() *PacketBuilder { @@ -231,7 +231,7 @@ func TestTracePacket(t *testing.T) { src := netip.PrefixFrom(netip.AddrFrom4([4]byte{1, 1, 1, 1}), 32) dst := netip.PrefixFrom(netip.AddrFrom4([4]byte{192, 168, 17, 2}), 32) - _, err := m.AddRouteFiltering(nil, []netip.Prefix{src}, fw.Network{Prefix: dst}, fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionDrop) + _, err := m.AddFilterRule(nil, []netip.Prefix{src}, fw.Network{Prefix: dst}, fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionDrop) require.NoError(t, err) }, packetBuilder: func() *PacketBuilder { @@ -332,7 +332,7 @@ func TestTracePacket(t *testing.T) { ip := net.ParseIP("1.1.1.1") proto := fw.ProtocolICMP action := fw.ActionAccept - _, err := m.AddPeerFiltering(nil, ip, proto, nil, nil, action, "") + _, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, nil, action) require.NoError(t, err) }, packetBuilder: func() *PacketBuilder { @@ -355,7 +355,7 @@ func TestTracePacket(t *testing.T) { ip := net.ParseIP("1.1.1.1") proto := fw.ProtocolICMP action := fw.ActionDrop - _, err := m.AddPeerFiltering(nil, ip, proto, nil, nil, action, "") + _, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, nil, action) require.NoError(t, err) }, packetBuilder: func() *PacketBuilder { @@ -379,7 +379,7 @@ func TestTracePacket(t *testing.T) { proto := fw.ProtocolUDP port := &fw.Port{Values: []uint16{53}} action := fw.ActionAccept - _, err := m.AddPeerFiltering(nil, ip, proto, nil, port, action, "") + _, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, port, action) require.NoError(t, err) }, packetBuilder: func() *PacketBuilder { @@ -423,7 +423,7 @@ func TestTracePacket(t *testing.T) { proto := fw.ProtocolTCP port := &fw.Port{Values: []uint16{80}} action := fw.ActionDrop - _, err := m.AddPeerFiltering(nil, ip, proto, nil, port, action, "") + _, err := m.AddFilterRule(nil, pfx(ip), fw.Network{}, proto, nil, port, action) require.NoError(t, err) }, packetBuilder: func() *PacketBuilder { diff --git a/client/grpc/dialer_generic.go b/client/grpc/dialer_generic.go index 479575996..737787223 100644 --- a/client/grpc/dialer_generic.go +++ b/client/grpc/dialer_generic.go @@ -16,28 +16,52 @@ import ( "google.golang.org/grpc" nbnet "github.com/netbirdio/netbird/client/net" + "github.com/netbirdio/netbird/client/netevents/sweep" ) +// Sweeper registers in-flight dials for the network change sweep. +type Sweeper interface { + StartDial(ctx context.Context) *sweep.Dial +} + 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 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 +} diff --git a/client/grpc/dialer_js.go b/client/grpc/dialer_js.go index b89ec3c21..4ff4ceb20 100644 --- a/client/grpc/dialer_js.go +++ b/client/grpc/dialer_js.go @@ -1,13 +1,26 @@ package grpc import ( + "context" + "google.golang.org/grpc" + "github.com/netbirdio/netbird/client/netevents/sweep" "github.com/netbirdio/netbird/util/wsproxy/client" ) +// Sweeper registers in-flight dials for the network change sweep. +type Sweeper interface { + StartDial(ctx context.Context) *sweep.Dial +} + // WithCustomDialer returns a gRPC dial option that uses WebSocket transport for WASM/JS environments. // The component parameter specifies the WebSocket proxy component path (e.g., "/management", "/signal"). 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(_ Sweeper) grpc.DialOption { + return grpc.EmptyDialOption{} +} diff --git a/client/grpc/retry.go b/client/grpc/retry.go new file mode 100644 index 000000000..0bb6037bf --- /dev/null +++ b/client/grpc/retry.go @@ -0,0 +1,56 @@ +package grpc + +import ( + "context" + "errors" + "time" + + "github.com/cenkalti/backoff/v4" +) + +// ChangeWatcher exposes OS network availability transitions. +type ChangeWatcher interface { + Changed() <-chan struct{} +} + +// 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 watcher never fires, leaving plain backoff.Retry +// behavior. +func Retry(ctx context.Context, operation backoff.Operation, bo backoff.BackOff, watcher ChangeWatcher) 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 + } + + var changed <-chan struct{} + if watcher != nil { + changed = watcher.Changed() + } + timer := time.NewTimer(next) + select { + case <-timer.C: + case <-changed: + timer.Stop() + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + } + } +} diff --git a/client/grpc/retry_test.go b/client/grpc/retry_test.go new file mode 100644 index 000000000..266bb93e5 --- /dev/null +++ b/client/grpc/retry_test.go @@ -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/netevents/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, "network change must cause one immediate retry") + 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, "permanent errors must stop retries") +} + +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, "nil network state must preserve timed retries") +} + +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, "stop backoff must return the operation error") +} + +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, "context cancellation must stop the retry loop") + assert.Less(t, time.Since(start), time.Second, "context cancellation must interrupt backoff sleep") +} diff --git a/client/iface/bind/ice_bind.go b/client/iface/bind/ice_bind.go index 156450c61..cd1ed12f1 100644 --- a/client/iface/bind/ice_bind.go +++ b/client/iface/bind/ice_bind.go @@ -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 } @@ -47,10 +57,13 @@ type ICEBind struct { endpoints map[netip.Addr]net.Conn endpointsMu sync.Mutex recvChan chan recvMessage - // every time when Close() is called (i.e. BindUpdate()) we need to close exit from the receiveRelayed and create a - // new closed channel. With the closedChanMu we can safely close the channel and create a new one + // Close() (i.e. BindUpdate()) closes closedChan to release receiveRelayed, + // and the following Open() installs a fresh one. closedChanMu guards both + // closedChan and closed: readers only ever hold it long enough to copy the + // channel, never across a blocking receive, so Open cannot be starved by a + // parked receiver. closedChan chan struct{} - closedChanMu sync.RWMutex // protect the closeChan recreation from reading from it. + closedChanMu sync.RWMutex closed bool activityRecorder *ActivityRecorder @@ -82,24 +95,41 @@ func NewICEBind(transportNet transport.Net, address wgaddr.Address, mtu uint16) } func (s *ICEBind) Open(uport uint16) ([]wgConn.ReceiveFunc, uint16, error) { - s.closed = false s.closedChanMu.Lock() - s.closedChan = make(chan struct{}) - s.closedChanMu.Unlock() + defer s.closedChanMu.Unlock() + + // Open the underlying bind before touching any state, so a failure leaves + // the current generation exactly as it was. Publishing the new generation + // first would strand it: StdNetBind rejects an Open while it is already + // open, and a Close arriving in that window would mark the bind closed + // while this call went on to install live sockets, after which every later + // Close returns early and never shuts them down. fns, port, err := s.StdNetBind.Open(uport) if err != nil { return nil, 0, err } + + // Release whoever is parked on the outgoing generation before replacing it. + // An Open that follows an Open rather than a Close would otherwise leave + // them waiting on a channel no later Close can reach. + if !s.closed { + close(s.closedChan) + } + s.closed = false + s.closedChan = make(chan struct{}) + fns = append(fns, s.receiveRelayed) return fns, port, nil } func (s *ICEBind) Close() error { + s.closedChanMu.Lock() + defer s.closedChanMu.Unlock() + if s.closed { return nil } s.closed = true - close(s.closedChan) s.muUDPMux.Lock() @@ -111,6 +141,15 @@ func (s *ICEBind) Close() error { return s.StdNetBind.Close() } +// currentClosedChan copies the channel that signals the current Open +// generation is closing. Callers select on the copy so the lock is never held +// across a blocking receive, which would otherwise stall the next Open. +func (s *ICEBind) currentClosedChan() chan struct{} { + s.closedChanMu.RLock() + defer s.closedChanMu.RUnlock() + return s.closedChan +} + func (s *ICEBind) ActivityRecorder() *ActivityRecorder { return s.activityRecorder } @@ -140,8 +179,10 @@ func (b *ICEBind) RemoveEndpoint(fakeIP netip.Addr) { } func (b *ICEBind) ReceiveFromEndpoint(ctx context.Context, ep *Endpoint, buf []byte) { + closedChan := b.currentClosedChan() + select { - case <-b.closedChan: + case <-closedChan: return case <-ctx.Done(): return @@ -216,8 +257,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 +319,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 @@ -311,11 +364,10 @@ func (s *ICEBind) parseSTUNMessage(raw []byte) (*stun.Message, error) { // receiveRelayed is a receive function that is used to receive packets from the relayed connection and forward to the // WireGuard. Critical part is do not block if the Closed() has been called. func (c *ICEBind) receiveRelayed(buffs [][]byte, sizes []int, eps []wgConn.Endpoint) (int, error) { - c.closedChanMu.RLock() - defer c.closedChanMu.RUnlock() + closedChan := c.currentClosedChan() select { - case <-c.closedChan: + case <-closedChan: return 0, net.ErrClosed case msg, ok := <-c.recvChan: if !ok { @@ -347,18 +399,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]) + msgType := binary.LittleEndian.Uint32(pkt[:4]) + return msgType >= wgMsgTypeHandshakeInitiation && msgType <= wgMsgTypeTransport +} - // Check if packetType matches known WireGuard message types - if packetType == 4 && n > 32 { - return true +// 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 } - return false + + msgType := binary.LittleEndian.Uint32(buffers[0][:4]) + return msgType == wgMsgTypeTransport && n > wgMinMsgSize } diff --git a/client/iface/bind/ice_bind_close_test.go b/client/iface/bind/ice_bind_close_test.go new file mode 100644 index 000000000..829d27663 --- /dev/null +++ b/client/iface/bind/ice_bind_close_test.go @@ -0,0 +1,261 @@ +package bind + +import ( + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + wgConn "golang.zx2c4.com/wireguard/conn" +) + +// startReceivers runs every receive function the way wireguard-go's device +// does: one goroutine per function, all tracked by a single WaitGroup. After +// calling Bind.Close, closeBindLocked waits on exactly that WaitGroup while +// holding device.net, so a receive function that never returns wedges the +// device and every goroutine that needs the same lock. +func startReceivers(fns []wgConn.ReceiveFunc) *sync.WaitGroup { + wg, _ := startReceiversEntered(fns) + return wg +} + +// startReceiversEntered also returns a channel closed once every receive +// function has been called at least once. A receive function that has been +// entered is either inside its blocking receive or about to be, which is a +// stronger signal to synchronise on than a bare sleep. +func startReceiversEntered(fns []wgConn.ReceiveFunc) (*sync.WaitGroup, <-chan struct{}) { + var wg sync.WaitGroup + var entered sync.WaitGroup + wg.Add(len(fns)) + entered.Add(len(fns)) + + for i := range fns { + go func(fn wgConn.ReceiveFunc) { + defer wg.Done() + buffs := [][]byte{make([]byte, 1500)} + sizes := make([]int, 1) + eps := make([]wgConn.Endpoint, 1) + first := true + for { + if first { + entered.Done() + first = false + } + if _, err := fn(buffs, sizes, eps); err != nil { + return + } + } + }(fns[i]) + } + + allEntered := make(chan struct{}) + go func() { + entered.Wait() + close(allEntered) + }() + return &wg, allEntered +} + +// closeBounded runs Close off the caller's goroutine so a regression that +// wedges it fails the test instead of hanging teardown, and reports whether it +// returned in time. +func closeBounded(iceBind *ICEBind, timeout time.Duration) bool { + done := make(chan struct{}) + go func() { + _ = iceBind.Close() + close(done) + }() + select { + case <-done: + return true + case <-time.After(timeout): + return false + } +} + +// isClosed reports whether the bind's current generation channel is closed. +func isClosed(iceBind *ICEBind) bool { + iceBind.closedChanMu.RLock() + ch := iceBind.closedChan + iceBind.closedChanMu.RUnlock() + select { + case <-ch: + return true + default: + return false + } +} + +// receiversStopped reports whether every receive function returned before the +// timeout, mirroring device.net.stopping.Wait() inside closeBindLocked. +func receiversStopped(wg *sync.WaitGroup, timeout time.Duration) bool { + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + select { + case <-done: + return true + case <-time.After(timeout): + return false + } +} + +// TestICEBindCloseReleasesReceivers covers the contract closeBindLocked relies +// on: once Close returns, every receive function handed out by Open must stop. +func TestICEBindCloseReleasesReceivers(t *testing.T) { + iceBind := setupICEBind(t) + + fns, _, err := iceBind.Open(0) + require.NoError(t, err, "opening the bind must succeed") + + wg := startReceivers(fns) + require.NoError(t, iceBind.Close()) + + require.True(t, receiversStopped(wg, 5*time.Second), + "every receive function must return once Close returns") +} + +// TestICEBindOpenDoesNotBlockOnParkedReceiver reproduces the deadlock that +// wedges interface creation. +// +// receiveRelayed used to hold closedChanMu for the whole of its blocking +// select, so a parked receiver kept the read lock indefinitely. Open takes the +// same mutex for writing to install a fresh closedChan, so it could never +// acquire it while a receiver was parked. wireguard-go reaches Open from +// Device.IpcSet and Device.Up with device.net held, so the stall takes the +// device's lock with it and every other device goroutine queues behind it. +// +// Failing here means Open never returned. +func TestICEBindOpenDoesNotBlockOnParkedReceiver(t *testing.T) { + iceBind := setupICEBind(t) + + fns, _, err := iceBind.Open(0) + require.NoError(t, err, "the first Open must succeed") + + wg, entered := startReceiversEntered(fns) + t.Cleanup(func() { + // Both bounded: a regression that wedges Close must surface as the + // assertion below, not as a hung teardown. + if !closeBounded(iceBind, 5*time.Second) { + t.Error("Close did not return during teardown; the bind lifecycle is wedged even though the assertion above passed") + } + if !receiversStopped(wg, 5*time.Second) { + t.Error("receive functions were still running after teardown Close, which is what closeBindLocked blocks on") + } + }) + + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("receive functions never started") + } + // Entered is not yet parked, so still allow the blocking receive to be + // reached. Parking takes microseconds; this margin is six orders larger. + time.Sleep(500 * time.Millisecond) + + reopened := make(chan struct{}) + go func() { + // The error is irrelevant; StdNetBind rejects a second Open. What + // matters is that the call returns at all. + _, _, _ = iceBind.Open(0) + close(reopened) + }() + + select { + case <-reopened: + case <-time.After(10 * time.Second): + t.Fatal("Open blocked while a receive function was parked; wireguard-go makes this call with device.net held, which is what stalls interface creation") + } +} + +// TestICEBindConcurrentOpenClose exercises Open and Close from separate +// goroutines, the way Device.IpcSet and Device.Up reach the bind, and is meant +// to be run under -race. +// +// closed and closedChan must be updated together. When they were not, Close +// could observe a stale closed and either skip close(closedChan) and +// StdNetBind.Close entirely, leaving the receive functions running, or race a +// second Close and close the same channel twice. +// +// Receive functions are deliberately not started here: this test is about the +// shared state, and parking them would turn a race report into a hang. +func TestICEBindConcurrentOpenClose(t *testing.T) { + iceBind := setupICEBind(t) + + var wg sync.WaitGroup + wg.Add(2) + + // Release both loops together so the calls genuinely interleave rather + // than depending on goroutine start order. + start := make(chan struct{}) + + go func() { + defer wg.Done() + <-start + for i := 0; i < 200; i++ { + _, _, _ = iceBind.Open(0) + } + }() + go func() { + defer wg.Done() + <-start + for i := 0; i < 200; i++ { + _ = iceBind.Close() + } + }() + + close(start) + wg.Wait() + require.NoError(t, iceBind.Close()) + require.True(t, isClosed(iceBind), "the final Close must leave the current generation channel closed") +} + +// TestICEBindCloseReleasesReceiversUnderConcurrentClose runs full Open, receive, +// Close cycles with a second Close and an Open racing the first Close. Any +// iteration where the receive functions outlive Close, or where the surviving +// generation channel is left open, is the state closeBindLocked deadlocks on. +func TestICEBindCloseReleasesReceiversUnderConcurrentClose(t *testing.T) { + if testing.Short() { + t.Skip("stress test") + } + + for i := 0; i < 200; i++ { + iceBind := setupICEBind(t) + + fns, _, err := iceBind.Open(0) + require.NoError(t, err, "iteration %d: opening the bind must succeed", i) + wg := startReceivers(fns) + + start := make(chan struct{}) + var racers sync.WaitGroup + racers.Add(3) + for c := 0; c < 2; c++ { + go func() { + defer racers.Done() + <-start + _ = iceBind.Close() + }() + } + // An Open overlapping the Closes is what produces a generation whose + // channel outlives the flag saying the bind is closed. + go func() { + defer racers.Done() + <-start + _, _, _ = iceBind.Open(0) + }() + close(start) + racers.Wait() + + // Settle on a closed bind whatever order the racers landed in. + _ = iceBind.Close() + + if !receiversStopped(wg, 5*time.Second) { + t.Fatalf("iteration %d: receive functions still running after Close; closeBindLocked would block here on device.net.stopping.Wait", i) + } + if !isClosed(iceBind) { + t.Fatalf("iteration %d: Close returned with the current generation channel still open, so nothing will ever release its receivers", i) + } + } +} diff --git a/client/iface/bind/stun_filter_test.go b/client/iface/bind/stun_filter_test.go new file mode 100644 index 000000000..0e118e0fd --- /dev/null +++ b/client/iface/bind/stun_filter_test.go @@ -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") +} diff --git a/client/iface/configurer/usp.go b/client/iface/configurer/usp.go index 0a25c55bc..2be1b861e 100644 --- a/client/iface/configurer/usp.go +++ b/client/iface/configurer/usp.go @@ -502,7 +502,7 @@ func toBytes(s string) (int64, error) { func getFwmark() int { if nbnet.AdvancedRouting() && runtime.GOOS == "linux" { - return nbnet.ControlPlaneMark + return int(nbnet.ControlPlaneMark) } return 0 } diff --git a/client/iface/device/device_android.go b/client/iface/device/device_android.go index cbe88c10c..0ed1299ae 100644 --- a/client/iface/device/device_android.go +++ b/client/iface/device/device_android.go @@ -63,7 +63,12 @@ func (t *WGTunDevice) Create(routes []string, dns string, searchDomains []string searchDomainsToString = "" } - fd, err := t.tunAdapter.ConfigureInterface(t.address.String(), t.address.IPv6String(), int(t.mtu), dns, searchDomainsToString, routesString) + ipv6Host := "" + if t.address.HasIPv6() { + ipv6Host = t.address.IPv6HostPrefix().String() + } + + fd, err := t.tunAdapter.ConfigureInterface(t.address.HostPrefix().String(), ipv6Host, int(t.mtu), dns, searchDomainsToString, routesString) if err != nil { log.Errorf("failed to create Android interface: %s", err) return nil, err diff --git a/client/iface/device/device_filter_test.go b/client/iface/device/device_filter_test.go index 0d86c9323..a75ef90f9 100644 --- a/client/iface/device/device_filter_test.go +++ b/client/iface/device/device_filter_test.go @@ -4,7 +4,7 @@ import ( "net" "testing" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/google/gopacket" "github.com/google/gopacket/layers" diff --git a/client/iface/iface_test.go b/client/iface/iface_test.go index 8ff2bbb54..89c8cd16e 100644 --- a/client/iface/iface_test.go +++ b/client/iface/iface_test.go @@ -464,6 +464,8 @@ func Test_RemovePeer(t *testing.T) { } func Test_ConnectPeers(t *testing.T) { + t.Setenv("NB_DISABLE_EBPF_WG_PROXY", "true") + peer1ifaceName := fmt.Sprintf("utun%d", WgIntNumber+400) peer1wgIP := netip.MustParsePrefix("10.99.99.17/30") peer1Key, _ := wgtypes.GeneratePrivateKey() @@ -505,12 +507,8 @@ func Test_ConnectPeers(t *testing.T) { t.Fatal(err) } - localIP, err := getLocalIP() - if err != nil { - t.Fatal(err) - } - - peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer1wgPort)) + localIP1 := "127.0.0.1" + peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP1, peer1wgPort)) if err != nil { t.Fatal(err) } @@ -546,7 +544,8 @@ func Test_ConnectPeers(t *testing.T) { t.Fatal(err) } - peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer2wgPort)) + localIP2 := "127.0.0.1" + peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP2, peer2wgPort)) if err != nil { t.Fatal(err) } @@ -569,17 +568,17 @@ func Test_ConnectPeers(t *testing.T) { if err != nil { t.Fatal(err) } - // todo: investigate why in some tests execution we need 30s + // The peers use userspace WireGuard (stdnet transport). A tight busy-loop + // here starves the wireguard-go goroutines that process the handshake, so + // poll on a ticker instead and yield the CPU between checks. WireGuard also + // only retries a lost handshake initiation every REKEY_TIMEOUT (5s), which + // is why the overall wait can occasionally stretch to tens of seconds. timeout := 30 * time.Second timeoutChannel := time.After(timeout) + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() for { - select { - case <-timeoutChannel: - t.Fatalf("waiting for peer handshake timeout after %s", timeout.String()) - default: - } - peer, gpErr := getPeer(peer1ifaceName, peer2Key.PublicKey().String()) if gpErr != nil { t.Fatal(gpErr) @@ -588,6 +587,12 @@ func Test_ConnectPeers(t *testing.T) { t.Log("peers successfully handshake") break } + + select { + case <-timeoutChannel: + t.Fatalf("waiting for peer handshake timeout after %s", timeout.String()) + case <-ticker.C: + } } } @@ -615,28 +620,3 @@ func getPeer(ifaceName, peerPubKey string) (wgtypes.Peer, error) { } return wgtypes.Peer{}, fmt.Errorf("peer not found") } - -func getLocalIP() (string, error) { - // Get all interfaces - addrs, err := net.InterfaceAddrs() - if err != nil { - return "", err - } - - for _, addr := range addrs { - ipNet, ok := addr.(*net.IPNet) - if !ok { - continue - } - if ipNet.IP.IsLoopback() { - continue - } - - if ipNet.IP.To4() == nil { - continue - } - return ipNet.IP.String(), nil - } - - return "", fmt.Errorf("no local IP found") -} diff --git a/client/iface/mocks/filter.go b/client/iface/mocks/filter.go index 5ae98039c..ff3dd0c8a 100644 --- a/client/iface/mocks/filter.go +++ b/client/iface/mocks/filter.go @@ -8,7 +8,7 @@ import ( "net/netip" reflect "reflect" - gomock "github.com/golang/mock/gomock" + gomock "go.uber.org/mock/gomock" ) // MockPacketFilter is a mock of PacketFilter interface. diff --git a/client/iface/mocks/tun.go b/client/iface/mocks/tun.go index 677c82b0b..519ee6005 100644 --- a/client/iface/mocks/tun.go +++ b/client/iface/mocks/tun.go @@ -8,7 +8,7 @@ import ( os "os" reflect "reflect" - gomock "github.com/golang/mock/gomock" + gomock "go.uber.org/mock/gomock" tun "golang.zx2c4.com/wireguard/tun" ) diff --git a/client/iface/netstack/env.go b/client/iface/netstack/env.go index dd8cf29a3..b069301c1 100644 --- a/client/iface/netstack/env.go +++ b/client/iface/netstack/env.go @@ -3,14 +3,31 @@ package netstack import ( - "fmt" + "net" "os" "strconv" log "github.com/sirupsen/logrus" ) -const EnvUseNetstackMode = "NB_USE_NETSTACK_MODE" +const ( + EnvUseNetstackMode = "NB_USE_NETSTACK_MODE" + + // EnvSocks5ListenerPort overrides the port the SOCKS5 proxy listens on. + EnvSocks5ListenerPort = "NB_SOCKS5_LISTENER_PORT" + + // EnvSocks5ListenerAddress overrides the host/IP the SOCKS5 proxy binds to. + // The proxy is a bridge for local host applications into the userspace + // WireGuard netstack, so it binds to loopback by default. Override this only + // when the proxy must be reachable from other hosts (e.g. a container + // gateway); doing so exposes an unauthenticated SOCKS5 proxy on that + // address. + EnvSocks5ListenerAddress = "NB_SOCKS5_LISTENER_ADDRESS" + + // defaultSocks5Host is the loopback address the SOCKS5 proxy binds to unless + // overridden via EnvSocks5ListenerAddress. + defaultSocks5Host = "127.0.0.1" +) // IsEnabled todo: move these function to cmd layer func IsEnabled() bool { @@ -18,24 +35,40 @@ func IsEnabled() bool { } func ListenAddr() string { - sPort := os.Getenv("NB_SOCKS5_LISTENER_PORT") + return net.JoinHostPort(listenHost(), strconv.Itoa(listenPort())) +} + +// listenHost returns the host/IP the SOCKS5 proxy binds to. It defaults to +// loopback and only honors EnvSocks5ListenerAddress when it holds a valid IP. +func listenHost() string { + addr := os.Getenv(EnvSocks5ListenerAddress) + if addr == "" { + return defaultSocks5Host + } + if net.ParseIP(addr) == nil { + log.Warnf("invalid socks5 listener address %q, falling back to default: %s", addr, defaultSocks5Host) + return defaultSocks5Host + } + return addr +} + +// listenPort returns the port the SOCKS5 proxy binds to, defaulting to +// DefaultSocks5Port when EnvSocks5ListenerPort is unset or invalid. +func listenPort() int { + sPort := os.Getenv(EnvSocks5ListenerPort) if sPort == "" { - return listenAddr(DefaultSocks5Port) + return DefaultSocks5Port } port, err := strconv.Atoi(sPort) if err != nil { log.Warnf("invalid socks5 listener port, unable to convert it to int, falling back to default: %d", DefaultSocks5Port) - return listenAddr(DefaultSocks5Port) + return DefaultSocks5Port } if port < 1 || port > 65535 { log.Warnf("invalid socks5 listener port, it should be in the range 1-65535, falling back to default: %d", DefaultSocks5Port) - return listenAddr(DefaultSocks5Port) + return DefaultSocks5Port } - return listenAddr(port) -} - -func listenAddr(port int) string { - return fmt.Sprintf("0.0.0.0:%d", port) + return port } diff --git a/client/iface/netstack/env_test.go b/client/iface/netstack/env_test.go new file mode 100644 index 000000000..1083435a4 --- /dev/null +++ b/client/iface/netstack/env_test.go @@ -0,0 +1,63 @@ +//go:build !js + +package netstack + +import ( + "net" + "strconv" + "testing" +) + +func TestListenAddr_DefaultsToLoopback(t *testing.T) { + // No env overrides: must bind loopback, never all interfaces. + got := ListenAddr() + want := net.JoinHostPort("127.0.0.1", strconv.Itoa(DefaultSocks5Port)) + if got != want { + t.Fatalf("ListenAddr() = %q, want %q", got, want) + } +} + +func TestListenAddr_AddressOverride(t *testing.T) { + tests := []struct { + name string + env string + want string + }{ + {name: "valid override honored", env: "0.0.0.0", want: "0.0.0.0"}, + {name: "valid specific ip honored", env: "10.0.0.5", want: "10.0.0.5"}, + {name: "ipv6 loopback bracketed", env: "::1", want: "::1"}, + {name: "invalid falls back to loopback", env: "not-an-ip", want: "127.0.0.1"}, + {name: "empty falls back to loopback", env: "", want: "127.0.0.1"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(EnvSocks5ListenerAddress, tc.env) + want := net.JoinHostPort(tc.want, strconv.Itoa(DefaultSocks5Port)) + if got := ListenAddr(); got != want { + t.Fatalf("ListenAddr() = %q, want %q", got, want) + } + }) + } +} + +func TestListenAddr_PortOverride(t *testing.T) { + tests := []struct { + name string + env string + want int + }{ + {name: "valid port honored", env: "1081", want: 1081}, + {name: "non-numeric falls back", env: "abc", want: DefaultSocks5Port}, + {name: "out of range falls back", env: "70000", want: DefaultSocks5Port}, + {name: "zero falls back", env: "0", want: DefaultSocks5Port}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(EnvSocks5ListenerPort, tc.env) + want := net.JoinHostPort("127.0.0.1", strconv.Itoa(tc.want)) + if got := ListenAddr(); got != want { + t.Fatalf("ListenAddr() = %q, want %q", got, want) + } + }) + } +} diff --git a/client/iface/wgaddr/address.go b/client/iface/wgaddr/address.go index 43d1ec9aa..148e724f4 100644 --- a/client/iface/wgaddr/address.go +++ b/client/iface/wgaddr/address.go @@ -59,6 +59,19 @@ func (addr Address) IPv6Prefix() netip.Prefix { return netip.PrefixFrom(addr.IPv6, addr.IPv6Net.Bits()) } +// HostPrefix returns the v4 address as a single-host prefix. +func (addr Address) HostPrefix() netip.Prefix { + return netip.PrefixFrom(addr.IP, addr.IP.BitLen()) +} + +// IPv6HostPrefix returns the v6 address as a single-host prefix, or an invalid prefix when no v6 overlay address is assigned. +func (addr Address) IPv6HostPrefix() netip.Prefix { + if !addr.HasIPv6() { + return netip.Prefix{} + } + return netip.PrefixFrom(addr.IPv6, addr.IPv6.BitLen()) +} + // SetIPv6FromCompact decodes a compact prefix (5 or 17 bytes) and sets the IPv6 fields. // Returns an error if the bytes are invalid. A nil or empty input is a no-op. // diff --git a/client/iface/wgaddr/address_test.go b/client/iface/wgaddr/address_test.go new file mode 100644 index 000000000..61478b24c --- /dev/null +++ b/client/iface/wgaddr/address_test.go @@ -0,0 +1,24 @@ +package wgaddr + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAddress_HostPrefix(t *testing.T) { + addr := MustParseWGAddress("100.91.96.107/16") + + assert.Equal(t, netip.MustParsePrefix("100.91.96.107/32"), addr.HostPrefix(), "v4 host prefix must be a single host") + assert.Equal(t, netip.MustParsePrefix("100.91.0.0/16"), addr.Network, "network must keep the overlay prefix length") + assert.False(t, addr.IPv6HostPrefix().IsValid(), "no v6 overlay means no v6 host prefix") +} + +func TestAddress_IPv6HostPrefix(t *testing.T) { + addr := MustParseWGAddress("100.91.96.107/16") + addr.IPv6 = netip.MustParseAddr("fd00:1234::1") + addr.IPv6Net = netip.MustParsePrefix("fd00:1234::/64") + + assert.Equal(t, netip.MustParsePrefix("fd00:1234::1/128"), addr.IPv6HostPrefix(), "v6 host prefix must be a single host") +} diff --git a/client/iface/wgproxy/bind/proxy.go b/client/iface/wgproxy/bind/proxy.go index be690ed4f..fcaee15c7 100644 --- a/client/iface/wgproxy/bind/proxy.go +++ b/client/iface/wgproxy/bind/proxy.go @@ -53,15 +53,15 @@ func NewProxyBind(bind Bind, mtu uint16) *ProxyBind { return p } -// AddTurnConn adds a new connection to the bind. +// AddRelayedConn adds a new connection to the bind. // endpoint is the NetBird address of the remote peer. The SetEndpoint return with the address what will be used in the // WireGuard configuration. // // Parameters: // - ctx: Context is used for proxyToLocal to avoid unnecessary error messages // - nbAddr: The NetBird UDP address of the remote peer, it required to generate fake address -// - remoteConn: The established TURN connection to the remote peer -func (p *ProxyBind) AddTurnConn(ctx context.Context, nbAddr *net.UDPAddr, remoteConn net.Conn) error { +// - remoteConn: The established relayed connection to the remote peer +func (p *ProxyBind) AddRelayedConn(ctx context.Context, nbAddr *net.UDPAddr, remoteConn net.Conn) error { fakeNetIP, err := fakeAddress(nbAddr) if err != nil { return err diff --git a/client/iface/wgproxy/ebpf/proxy.go b/client/iface/wgproxy/ebpf/proxy.go index 1b1a8ce1c..91c741c0d 100644 --- a/client/iface/wgproxy/ebpf/proxy.go +++ b/client/iface/wgproxy/ebpf/proxy.go @@ -30,9 +30,9 @@ type WGEBPFProxy struct { proxyPort int mtu uint16 - ebpfManager ebpfMgr.Manager - turnConnStore map[uint16]net.Conn - turnConnMutex sync.Mutex + ebpfManager ebpfMgr.Manager + relayedConnStore map[uint16]net.Conn + relayedConnMutex sync.Mutex lastUsedPort uint16 rawConnIPv4 net.PacketConn @@ -50,7 +50,7 @@ func NewWGEBPFProxy(wgPort int, mtu uint16) *WGEBPFProxy { localWGListenPort: wgPort, mtu: mtu, ebpfManager: ebpf.GetEbpfManagerInstance(), - turnConnStore: make(map[uint16]net.Conn), + relayedConnStore: make(map[uint16]net.Conn), } return wgProxy } @@ -110,14 +110,14 @@ func (p *WGEBPFProxy) Listen() error { return nil } -// AddTurnConn add new turn connection for the proxy -func (p *WGEBPFProxy) AddTurnConn(turnConn net.Conn) (*net.UDPAddr, error) { - wgEndpointPort, err := p.storeTurnConn(turnConn) +// AddRelayedConn add new relayed connection for the proxy +func (p *WGEBPFProxy) AddRelayedConn(relayedConn net.Conn) (*net.UDPAddr, error) { + wgEndpointPort, err := p.storeRelayedConn(relayedConn) if err != nil { return nil, err } - log.Infof("turn conn added to wg proxy store: %s, endpoint port: :%d", turnConn.RemoteAddr(), wgEndpointPort) + log.Infof("relayed conn added to wg proxy store: %s, endpoint port: :%d", relayedConn.RemoteAddr(), wgEndpointPort) wgEndpoint := &net.UDPAddr{ IP: net.ParseIP(loopbackAddr), @@ -186,48 +186,48 @@ func (p *WGEBPFProxy) readAndForwardPacket(buf []byte) error { return fmt.Errorf("failed to read UDP packet from WG: %w", err) } - p.turnConnMutex.Lock() - conn, ok := p.turnConnStore[uint16(addr.Port)] - p.turnConnMutex.Unlock() + p.relayedConnMutex.Lock() + conn, ok := p.relayedConnStore[uint16(addr.Port)] + p.relayedConnMutex.Unlock() if !ok { if p.ctx.Err() == nil { - log.Debugf("turn conn not found by port because conn already has been closed: %d", addr.Port) + log.Debugf("relayed conn not found by port because conn already has been closed: %d", addr.Port) } return nil } if _, err := conn.Write(buf[:n]); err != nil { - return fmt.Errorf("failed to forward local WG packet (%d) to remote turn conn: %w", addr.Port, err) + return fmt.Errorf("forward local WG packet (%d) to remote relayed conn: %w", addr.Port, err) } return nil } -func (p *WGEBPFProxy) storeTurnConn(turnConn net.Conn) (uint16, error) { - p.turnConnMutex.Lock() - defer p.turnConnMutex.Unlock() +func (p *WGEBPFProxy) storeRelayedConn(relayedConn net.Conn) (uint16, error) { + p.relayedConnMutex.Lock() + defer p.relayedConnMutex.Unlock() np, err := p.nextFreePort() if err != nil { return np, err } - p.turnConnStore[np] = turnConn + p.relayedConnStore[np] = relayedConn return np, nil } -func (p *WGEBPFProxy) removeTurnConn(turnConnID uint16) { - p.turnConnMutex.Lock() - defer p.turnConnMutex.Unlock() +func (p *WGEBPFProxy) removeRelayedConn(relayedConnID uint16) { + p.relayedConnMutex.Lock() + defer p.relayedConnMutex.Unlock() - _, ok := p.turnConnStore[turnConnID] + _, ok := p.relayedConnStore[relayedConnID] if ok { - log.Debugf("remove turn conn from store by port: %d", turnConnID) + log.Debugf("remove relayed conn from store by port: %d", relayedConnID) } - delete(p.turnConnStore, turnConnID) + delete(p.relayedConnStore, relayedConnID) } func (p *WGEBPFProxy) nextFreePort() (uint16, error) { - if len(p.turnConnStore) == 65535 { - return 0, fmt.Errorf("reached maximum turn connection numbers") + if len(p.relayedConnStore) == 65535 { + return 0, fmt.Errorf("reached maximum relayed connection numbers") } generatePort: if p.lastUsedPort == 65535 { @@ -236,7 +236,7 @@ generatePort: p.lastUsedPort++ } - if _, ok := p.turnConnStore[p.lastUsedPort]; ok { + if _, ok := p.relayedConnStore[p.lastUsedPort]; ok { goto generatePort } return p.lastUsedPort, nil diff --git a/client/iface/wgproxy/ebpf/proxy_test.go b/client/iface/wgproxy/ebpf/proxy_test.go index 3ec4f0eba..228c06c9b 100644 --- a/client/iface/wgproxy/ebpf/proxy_test.go +++ b/client/iface/wgproxy/ebpf/proxy_test.go @@ -9,32 +9,32 @@ import ( func TestWGEBPFProxy_connStore(t *testing.T) { wgProxy := NewWGEBPFProxy(1, 1280) - p, _ := wgProxy.storeTurnConn(nil) + p, _ := wgProxy.storeRelayedConn(nil) if p != 1 { t.Errorf("invalid initial port: %d", wgProxy.lastUsedPort) } numOfConns := 10 for i := 0; i < numOfConns; i++ { - p, _ = wgProxy.storeTurnConn(nil) + p, _ = wgProxy.storeRelayedConn(nil) } if p != uint16(numOfConns)+1 { t.Errorf("invalid last used port: %d, expected: %d", p, numOfConns+1) } - if len(wgProxy.turnConnStore) != numOfConns+1 { - t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.turnConnStore), numOfConns+1) + if len(wgProxy.relayedConnStore) != numOfConns+1 { + t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.relayedConnStore), numOfConns+1) } } func TestWGEBPFProxy_portCalculation_overflow(t *testing.T) { wgProxy := NewWGEBPFProxy(1, 1280) - _, _ = wgProxy.storeTurnConn(nil) + _, _ = wgProxy.storeRelayedConn(nil) wgProxy.lastUsedPort = 65535 - p, _ := wgProxy.storeTurnConn(nil) + p, _ := wgProxy.storeRelayedConn(nil) - if len(wgProxy.turnConnStore) != 2 { - t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.turnConnStore), 2) + if len(wgProxy.relayedConnStore) != 2 { + t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.relayedConnStore), 2) } if p != 2 { @@ -46,11 +46,11 @@ func TestWGEBPFProxy_portCalculation_maxConn(t *testing.T) { wgProxy := NewWGEBPFProxy(1, 1280) for i := 0; i < 65535; i++ { - _, _ = wgProxy.storeTurnConn(nil) + _, _ = wgProxy.storeRelayedConn(nil) } - _, err := wgProxy.storeTurnConn(nil) + _, err := wgProxy.storeRelayedConn(nil) if err == nil { - t.Errorf("invalid turn conn store calculation") + t.Errorf("invalid relayed conn store calculation") } } diff --git a/client/iface/wgproxy/ebpf/wrapper.go b/client/iface/wgproxy/ebpf/wrapper.go index a6156a661..f75e21aa6 100644 --- a/client/iface/wgproxy/ebpf/wrapper.go +++ b/client/iface/wgproxy/ebpf/wrapper.go @@ -121,10 +121,10 @@ func NewProxyWrapper(proxy *WGEBPFProxy) *ProxyWrapper { } } -func (p *ProxyWrapper) AddTurnConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error { - addr, err := p.wgeBPFProxy.AddTurnConn(remoteConn) +func (p *ProxyWrapper) AddRelayedConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error { + addr, err := p.wgeBPFProxy.AddRelayedConn(remoteConn) if err != nil { - return fmt.Errorf("add turn conn: %w", err) + return fmt.Errorf("add relayed conn: %w", err) } headers, err := NewPacketHeaders(p.wgeBPFProxy.localWGListenPort, addr) @@ -252,7 +252,7 @@ func (p *ProxyWrapper) CloseConn() error { } func (p *ProxyWrapper) proxyToLocal(ctx context.Context) { - defer p.wgeBPFProxy.removeTurnConn(uint16(p.wgRelayedEndpointAddr.Port)) + defer p.wgeBPFProxy.removeRelayedConn(uint16(p.wgRelayedEndpointAddr.Port)) buf := make([]byte, p.wgeBPFProxy.mtu+bufsize.WGBufferOverhead) for { @@ -273,7 +273,7 @@ func (p *ProxyWrapper) proxyToLocal(ctx context.Context) { if ctx.Err() != nil { return } - log.Errorf("failed to write out turn pkg to local conn: %v", err) + log.Errorf("failed to write out relayed pkg to local conn: %v", err) } } } @@ -286,7 +286,7 @@ func (p *ProxyWrapper) readFromRemote(ctx context.Context, buf []byte) (int, err } p.closeListener.Notify() if !errors.Is(err, io.EOF) { - log.Errorf("failed to read from turn conn (endpoint: :%d): %s", p.wgRelayedEndpointAddr.Port, err) + log.Errorf("failed to read from relayed conn (endpoint: :%d): %s", p.wgRelayedEndpointAddr.Port, err) } return 0, err } diff --git a/client/iface/wgproxy/proxy.go b/client/iface/wgproxy/proxy.go index 40346bc15..b0033bffa 100644 --- a/client/iface/wgproxy/proxy.go +++ b/client/iface/wgproxy/proxy.go @@ -7,7 +7,7 @@ import ( // Proxy is a transfer layer between the relayed connection and the WireGuard type Proxy interface { - AddTurnConn(ctx context.Context, endpoint *net.UDPAddr, remoteConn net.Conn) error + AddRelayedConn(ctx context.Context, endpoint *net.UDPAddr, remoteConn net.Conn) error EndpointAddr() *net.UDPAddr // EndpointAddr returns the address of the WireGuard peer endpoint Work() // Work start or resume the proxy Pause() // Pause to forward the packages from remote connection to WireGuard. The opposite way still works. diff --git a/client/iface/wgproxy/proxy_test.go b/client/iface/wgproxy/proxy_test.go index 1aeab66b7..d86cdbe80 100644 --- a/client/iface/wgproxy/proxy_test.go +++ b/client/iface/wgproxy/proxy_test.go @@ -95,7 +95,7 @@ func TestProxyCloseByRemoteConn(t *testing.T) { t.Run(tt.name, func(t *testing.T) { addr, _ := net.ResolveUDPAddr("udp", "100.108.135.221:51892") relayedConn := newMockConn() - err := tt.proxy.AddTurnConn(ctx, addr, relayedConn) + err := tt.proxy.AddRelayedConn(ctx, addr, relayedConn) if err != nil { t.Errorf("error: %v", err) } @@ -157,7 +157,7 @@ func redirectTraffic(t *testing.T, proxy Proxy, wgPort int, endPointAddr *net.UD _ = relayedServer.Close() }() - if err := proxy.AddTurnConn(context.Background(), endPointAddr, relayedConn); err != nil { + if err := proxy.AddRelayedConn(context.Background(), endPointAddr, relayedConn); err != nil { t.Errorf("error: %v", err) } defer func() { diff --git a/client/iface/wgproxy/rawsocket/rawsocket.go b/client/iface/wgproxy/rawsocket/rawsocket.go index bc785b43a..37aaa160f 100644 --- a/client/iface/wgproxy/rawsocket/rawsocket.go +++ b/client/iface/wgproxy/rawsocket/rawsocket.go @@ -10,8 +10,6 @@ import ( log "github.com/sirupsen/logrus" "golang.org/x/sys/unix" - - nbnet "github.com/netbirdio/netbird/client/net" ) // PrepareSenderRawSocketIPv4 creates and configures a raw socket for sending IPv4 packets @@ -60,14 +58,12 @@ func prepareSenderRawSocket(family int, isIPv4 bool) (net.PacketConn, error) { return nil, fmt.Errorf("binding to lo interface failed: %w", err) } - // Set the fwmark on the socket. - err = nbnet.SetSocketOpt(fd) - if err != nil { - if closeErr := syscall.Close(fd); closeErr != nil { - log.Warnf("failed to close raw socket fd: %v", closeErr) - } - return nil, fmt.Errorf("setting fwmark failed: %w", err) - } + // The socket is bound to lo and only ever sends to the local WireGuard + // instance, a destination the local routing table resolves without help, so + // it carries no fwmark. Staying unmarked also keeps these packets out of + // third-party NAT rules that match on marks: such a rule rewriting the + // source would make WireGuard adopt the rewritten address as the peer + // endpoint. // Convert the file descriptor to a PacketConn. file := os.NewFile(uintptr(fd), fmt.Sprintf("fd %d", fd)) diff --git a/client/iface/wgproxy/rawsocket/rawsocket_privileged_test.go b/client/iface/wgproxy/rawsocket/rawsocket_privileged_test.go new file mode 100644 index 000000000..03748c6f9 --- /dev/null +++ b/client/iface/wgproxy/rawsocket/rawsocket_privileged_test.go @@ -0,0 +1,77 @@ +//go:build linux && !android && privileged + +package rawsocket + +import ( + "net" + "syscall" + "testing" + + "golang.org/x/sys/unix" + + nbnet "github.com/netbirdio/netbird/client/net" +) + +// The sender sockets must stay unmarked: a NAT rule matching on fwmark that +// rewrites the source of an injected packet makes WireGuard adopt the rewritten +// address as the peer endpoint. +func TestSenderRawSocketsCarryNoFwmark(t *testing.T) { + // the mark is only ever applied when advanced routing is available, so + // without it the assertion below would hold for the wrong reason + nbnet.Init() + if !nbnet.AdvancedRouting() { + t.Skip("advanced routing unsupported, the sockets carry no mark either way") + } + + tests := []struct { + name string + prepare func() (net.PacketConn, error) + // the proxy treats the IPv6 socket as optional, so a host without IPv6 + // is a reason to skip rather than to fail + optional bool + }{ + {name: "IPv4", prepare: PrepareSenderRawSocketIPv4}, + {name: "IPv6", prepare: PrepareSenderRawSocketIPv6, optional: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + conn, err := tc.prepare() + if err != nil { + if tc.optional { + t.Skipf("prepare raw socket: %v", err) + } + t.Fatalf("prepare raw socket: %v", err) + } + defer func() { + if err := conn.Close(); err != nil { + t.Logf("close raw socket: %v", err) + } + }() + + syscallConn, ok := conn.(syscall.Conn) + if !ok { + t.Fatalf("raw socket %T does not expose a syscall conn", conn) + } + raw, err := syscallConn.SyscallConn() + if err != nil { + t.Fatalf("syscall conn: %v", err) + } + + var mark int + var markErr error + if err := raw.Control(func(fd uintptr) { + mark, markErr = unix.GetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_MARK) + }); err != nil { + t.Fatalf("control: %v", err) + } + if markErr != nil { + t.Fatalf("get SO_MARK: %v", markErr) + } + + if mark != 0 { + t.Errorf("SO_MARK = %#x, want 0", mark) + } + }) + } +} diff --git a/client/iface/wgproxy/redirect_test.go b/client/iface/wgproxy/redirect_test.go index 135970838..f0d59cc64 100644 --- a/client/iface/wgproxy/redirect_test.go +++ b/client/iface/wgproxy/redirect_test.go @@ -119,9 +119,9 @@ func testRedirectAs(t *testing.T, proxy Proxy, wgPort int, nbAddr, p2pEndpoint * } defer relayConn.Close() - // Add TURN connection to proxy - if err := proxy.AddTurnConn(ctx, nbAddr, relayConn); err != nil { - t.Fatalf("failed to add TURN connection: %v", err) + // Add relayed connection to proxy + if err := proxy.AddRelayedConn(ctx, nbAddr, relayConn); err != nil { + t.Fatalf("failed to add relayed connection: %v", err) } defer func() { if err := proxy.CloseConn(); err != nil { @@ -304,8 +304,8 @@ func TestRedirectAs_Multiple_Switches(t *testing.T) { Port: 38746, } - if err := proxy.AddTurnConn(ctx, nbAddr, relayConn); err != nil { - t.Fatalf("failed to add TURN connection: %v", err) + if err := proxy.AddRelayedConn(ctx, nbAddr, relayConn); err != nil { + t.Fatalf("failed to add relayed connection: %v", err) } defer func() { if err := proxy.CloseConn(); err != nil { diff --git a/client/iface/wgproxy/udp/proxy.go b/client/iface/wgproxy/udp/proxy.go index 783843aba..a0895c8c7 100644 --- a/client/iface/wgproxy/udp/proxy.go +++ b/client/iface/wgproxy/udp/proxy.go @@ -51,12 +51,12 @@ func NewWGUDPProxy(wgPort int, mtu uint16) *WGUDPProxy { return p } -// AddTurnConn +// AddRelayedConn dials the local WireGuard port and stores the relayed connection. // The provided Context must be non-nil. If the context expires before // the connection is complete, an error is returned. Once successfully // connected, any expiration of the context will not affect the // connection. -func (p *WGUDPProxy) AddTurnConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error { +func (p *WGUDPProxy) AddRelayedConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error { dialer := net.Dialer{} localConn, err := dialer.DialContext(ctx, "udp", fmt.Sprintf(":%d", p.localWGListenPort)) if err != nil { diff --git a/client/installer.nsis b/client/installer.nsis index 71699071b..eb2d7d5bd 100644 --- a/client/installer.nsis +++ b/client/installer.nsis @@ -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" diff --git a/client/internal/acl/dispatch_test.go b/client/internal/acl/dispatch_test.go new file mode 100644 index 000000000..be82e414f --- /dev/null +++ b/client/internal/acl/dispatch_test.go @@ -0,0 +1,190 @@ +package acl + +import ( + "net/netip" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/netbirdio/netbird/client/firewall" + fwmgr "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/internal/acl/mocks" + mgmProto "github.com/netbirdio/netbird/shared/management/proto" +) + +// TestNetworkZeroPrefixIsRoute guards the route-vs-peer dispatch +// invariant: the backends classify a rule as a peer rule purely by the +// absence of a destination (neither prefix nor set). A default route +// (0.0.0.0/0 or ::/0) is a valid prefix and must therefore classify as +// a route, not collapse into the peer path. +func TestNetworkZeroPrefixIsRoute(t *testing.T) { + for _, p := range []string{"0.0.0.0/0", "::/0", "10.0.0.0/8"} { + n := fwmgr.Network{Prefix: netip.MustParsePrefix(p)} + assert.True(t, n.IsPrefix(), "%s must report IsPrefix", p) + assert.True(t, n.IsPrefix() || n.IsSet(), "%s must classify as a route", p) + } + + // A zero-value Network is the only peer-rule shape. + var empty fwmgr.Network + assert.False(t, empty.IsPrefix(), "zero Network must not be a prefix") + assert.False(t, empty.IsSet(), "zero Network must not be a set") +} + +// TestDetermineDestinationAlwaysRoute verifies determineDestination +// never yields an empty Network for a valid route rule: every branch +// (static prefix, default route, dynamic with/without domains, with and +// without a local resolver) produces a destination that classifies as a +// route. If this regresses, a route rule would be dispatched down the +// peer path, which matches on source only. +func TestDetermineDestinationAlwaysRoute(t *testing.T) { + v4 := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24")} + v6 := []netip.Prefix{netip.MustParsePrefix("2001:db8::/48")} + + cases := []struct { + name string + rule *mgmProto.RouteFirewallRule + resolver bool + sources []netip.Prefix + }{ + {"static prefix", &mgmProto.RouteFirewallRule{Destination: "192.168.0.0/16"}, false, v4}, + {"static default route", &mgmProto.RouteFirewallRule{Destination: "0.0.0.0/0"}, false, v4}, + {"dynamic with domains + resolver", &mgmProto.RouteFirewallRule{IsDynamic: true, Domains: []string{"example.com"}}, true, v4}, + {"dynamic no domains + resolver (v4)", &mgmProto.RouteFirewallRule{IsDynamic: true}, true, v4}, + {"dynamic no domains + resolver (v6)", &mgmProto.RouteFirewallRule{IsDynamic: true}, true, v6}, + {"dynamic + no local resolver (v4)", &mgmProto.RouteFirewallRule{IsDynamic: true}, false, v4}, + {"dynamic + no local resolver (v6)", &mgmProto.RouteFirewallRule{IsDynamic: true}, false, v6}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dest, err := determineDestination(tc.rule, tc.resolver, tc.sources) + require.NoError(t, err) + assert.True(t, dest.IsPrefix() || dest.IsSet(), + "destination must classify as a route, got empty Network") + }) + } +} + +// countingFirewall wraps a real firewall.Manager and counts filter-rule +// add/delete calls so a test can assert how many backing rules the acl +// manager actually creates and tears down. +type countingFirewall struct { + fwmgr.Manager + mu sync.Mutex + addCalls int + dels int + ruleIDs map[fwmgr.RuleID]struct{} +} + +// distinctRules returns the number of distinct backing rules the +// backend produced. Because the backend dedups identical content, +// repeated AddFilterRule calls for the same rule resolve to one id. +func (f *countingFirewall) distinctRules() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.ruleIDs) +} + +func (f *countingFirewall) AddFilterRule(id []byte, sources []netip.Prefix, destination fwmgr.Network, proto fwmgr.Protocol, sPort, dPort *fwmgr.Port, action fwmgr.Action) (fwmgr.Rule, error) { + rule, err := f.Manager.AddFilterRule(id, sources, destination, proto, sPort, dPort, action) + if err == nil { + f.mu.Lock() + f.addCalls++ + if f.ruleIDs == nil { + f.ruleIDs = make(map[fwmgr.RuleID]struct{}) + } + if rule != nil { + f.ruleIDs[rule.ID()] = struct{}{} + } + f.mu.Unlock() + } + return rule, err +} + +func (f *countingFirewall) DeleteFilterRule(r fwmgr.Rule) error { + err := f.Manager.DeleteFilterRule(r) + if err == nil { + f.mu.Lock() + f.dels++ + delete(f.ruleIDs, r.ID()) + f.mu.Unlock() + } + return err +} + +func newCountingACL(t *testing.T) (*DefaultManager, *countingFirewall, func()) { + t.Helper() + t.Setenv("NB_WG_KERNEL_DISABLED", "true") + t.Setenv(firewall.EnvForceUserspaceFirewall, "true") + + ctrl := gomock.NewController(t) + ifaceMock := mocks.NewMockIFaceMapper(ctrl) + ifaceMock.EXPECT().IsUserspaceBind().Return(true).AnyTimes() + ifaceMock.EXPECT().SetFilter(gomock.Any()) + network := netip.MustParsePrefix("172.0.0.1/32") + ifaceMock.EXPECT().Name().Return("lo").AnyTimes() + ifaceMock.EXPECT().Address().Return(wgaddr.Address{IP: network.Addr(), Network: network}).AnyTimes() + ifaceMock.EXPECT().GetWGDevice().Return(nil).AnyTimes() + + realFW, err := firewall.NewFirewall(ifaceMock, nil, flowLogger, false, iface.DefaultMTU) + require.NoError(t, err) + + fw := &countingFirewall{Manager: realFW} + cleanup := func() { + require.NoError(t, realFW.Close(nil)) + ctrl.Finish() + } + return NewDefaultManager(fw), fw, cleanup +} + +// TestDuplicateContentPoliciesShareOneRule verifies the dedup contract +// the backends rely on: two policies that authorize an identical flow +// (same selector and sources) collapse to a single backing firewall +// rule, and that rule survives until BOTH policies are gone. This is +// why the backend can dedup on add without refcounting on delete: the +// acl manager's pair key matches the backend's content key, so add and +// delete stay balanced per content key across full-state reapplies. +func TestDuplicateContentPoliciesShareOneRule(t *testing.T) { + acl, fw, cleanup := newCountingACL(t) + defer cleanup() + + ruleA := &mgmProto.FirewallRule{ + PolicyID: []byte("policy-A"), + PeerIP: "10.0.0.1", //nolint:staticcheck + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "443", + } + ruleB := &mgmProto.FirewallRule{ + PolicyID: []byte("policy-B"), + PeerIP: "10.0.0.1", //nolint:staticcheck + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "443", + } + + // Both policies present: identical content collapses to one rule. + acl.ApplyFiltering(&mgmProto.NetworkMap{FirewallRules: []*mgmProto.FirewallRule{ruleA, ruleB}, FirewallRulesIsEmpty: false}, false) + assert.Equal(t, 1, fw.distinctRules(), "identical-content policies must produce one backing rule") + assert.Equal(t, 1, len(acl.peerRulesPairs), "one content key, one pair") + + // Drop policy A only: the shared rule is still authorized by B, so + // nothing is deleted. + acl.ApplyFiltering(&mgmProto.NetworkMap{FirewallRules: []*mgmProto.FirewallRule{ruleB}, FirewallRulesIsEmpty: false}, false) + assert.Equal(t, 1, fw.distinctRules(), "no new backing rule on reapply") + assert.Equal(t, 0, fw.dels, "rule must survive while any policy still authorizes it") + assert.Equal(t, 1, len(acl.peerRulesPairs)) + + // Drop policy B too: now the content key has no authorizer and the + // single backing rule is removed exactly once. + acl.ApplyFiltering(&mgmProto.NetworkMap{FirewallRules: nil, FirewallRulesIsEmpty: true}, false) + assert.Equal(t, 1, fw.dels, "rule removed once when last policy is gone") + assert.Equal(t, 0, len(acl.peerRulesPairs)) +} diff --git a/client/internal/acl/grouping_test.go b/client/internal/acl/grouping_test.go new file mode 100644 index 000000000..d6cf29b59 --- /dev/null +++ b/client/internal/acl/grouping_test.go @@ -0,0 +1,318 @@ +package acl + +import ( + "errors" + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/netbirdio/netbird/client/firewall" + fwmgr "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/internal/acl/mocks" + mgmProto "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/shared/netiputil" +) + +// TestGroupPeerRulesPolicyIDSeparates verifies that two FirewallRules +// with identical selectors but different PolicyIDs do NOT get merged +// into one group, so each policy's sources merge under its own +// attribution id. (Identical-content groups may still dedup to one +// backing rule at the backend; see TestDuplicateContentPoliciesShareOneRule.) +func TestGroupPeerRulesPolicyIDSeparates(t *testing.T) { + rules := []*mgmProto.FirewallRule{ + { + PolicyID: []byte("policy-A"), + PeerIP: "10.0.0.1", + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "443", + }, + { + PolicyID: []byte("policy-B"), + PeerIP: "10.0.0.1", + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "443", + }, + } + + groups, denyErr, err := groupPeerRules(rules) + require.NoError(t, denyErr) + require.NoError(t, err) + require.Len(t, groups, 2, "rules with different PolicyIDs must produce separate groups") +} + +// TestGroupPeerRulesFamilySeparates verifies that v4 and v6 rules +// belonging to the same policy don't merge. +func TestGroupPeerRulesFamilySeparates(t *testing.T) { + rules := []*mgmProto.FirewallRule{ + { + PolicyID: []byte("policy-A"), + PeerIP: "10.0.0.1", + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "443", + }, + { + PolicyID: []byte("policy-A"), + PeerIP: "2001:db8::1", + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "443", + }, + } + + groups, denyErr, err := groupPeerRules(rules) + require.NoError(t, denyErr) + require.NoError(t, err) + require.Len(t, groups, 2, "rules of different families must produce separate groups") + + var sawV4, sawV6 bool + for _, g := range groups { + require.Len(t, g.sources, 1) + if g.sources[0].Addr().Is4() { + sawV4 = true + } + if g.sources[0].Addr().Is6() { + sawV6 = true + } + } + assert.True(t, sawV4 && sawV6) +} + +// TestGroupPeerRulesSplitsMixedFamilySingleRule verifies that a single +// FirewallRule carrying both v4 and v6 source prefixes is split into one +// group per family. Each backend keys a rule to a single family, so a +// group whose sources span families would mismatch the other family's +// sources. mgmt normally emits one rule per family; this guards against +// a mixed-family rule slipping through. +func TestGroupPeerRulesSplitsMixedFamilySingleRule(t *testing.T) { + srcs := [][]byte{ + netiputil.EncodeAddr(netip.MustParseAddr("10.0.0.1")), + netiputil.EncodeAddr(netip.MustParseAddr("2001:db8::1")), + netiputil.EncodeAddr(netip.MustParseAddr("10.0.0.2")), + netiputil.EncodeAddr(netip.MustParseAddr("2001:db8::2")), + } + rules := []*mgmProto.FirewallRule{ + { + PolicyID: []byte("policy-A"), + SourcePrefixes: srcs, + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "443", + }, + } + + groups, denyErr, err := groupPeerRules(rules) + require.NoError(t, denyErr) + require.NoError(t, err) + require.Len(t, groups, 2, "mixed-family sources in one rule must split into two groups") + + for _, g := range groups { + require.Len(t, g.sources, 2) + v6 := prefixIsV6(g.sources[0]) + for _, s := range g.sources { + assert.Equal(t, v6, prefixIsV6(s), "every source in a group must share one family") + } + } +} + +// TestGroupPeerRulesMergesSameSelector verifies that rules sharing +// every distinguishing field (policy, family, direction, action, +// proto, port) collapse into a single multi-source group. +func TestGroupPeerRulesMergesSameSelector(t *testing.T) { + mk := func(peerIP string) *mgmProto.FirewallRule { + return &mgmProto.FirewallRule{ + PolicyID: []byte("policy-A"), + PeerIP: peerIP, //nolint:staticcheck + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "443", + } + } + rules := []*mgmProto.FirewallRule{mk("10.0.0.1"), mk("10.0.0.2"), mk("10.0.0.3")} + + groups, denyErr, err := groupPeerRules(rules) + require.NoError(t, denyErr) + require.NoError(t, err) + require.Len(t, groups, 1) + require.Len(t, groups[0].sources, 3) +} + +// TestGroupPeerRulesPortSeparates verifies that PortInfo is part of the +// selector key: rules differing only in port must not merge, and a +// single port must not merge with a range. A regression dropping the +// port from the key would collapse rules for different ports into one. +func TestGroupPeerRulesPortSeparates(t *testing.T) { + mkPort := func(peerIP string, port uint32) *mgmProto.FirewallRule { + return &mgmProto.FirewallRule{ + PolicyID: []byte("policy-A"), + PeerIP: peerIP, //nolint:staticcheck + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + PortInfo: &mgmProto.PortInfo{PortSelection: &mgmProto.PortInfo_Port{Port: port}}, + } + } + + groups, denyErr, err := groupPeerRules([]*mgmProto.FirewallRule{ + mkPort("10.0.0.1", 80), mkPort("10.0.0.2", 80), mkPort("10.0.0.3", 443), + }) + require.NoError(t, denyErr) + require.NoError(t, err) + require.Len(t, groups, 2, "rules on different ports must not merge") + + rangeRule := &mgmProto.FirewallRule{ + PolicyID: []byte("policy-A"), + PeerIP: "10.0.0.4", //nolint:staticcheck + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + PortInfo: &mgmProto.PortInfo{PortSelection: &mgmProto.PortInfo_Range_{Range: &mgmProto.PortInfo_Range{Start: 80, End: 90}}}, + } + groups, denyErr, err = groupPeerRules([]*mgmProto.FirewallRule{mkPort("10.0.0.1", 80), rangeRule}) + require.NoError(t, denyErr) + require.NoError(t, err) + require.Len(t, groups, 2, "a single port and a range must not merge") +} + +// TestGroupPeerRulesUsesSourcePrefixesWhenPresent verifies that the +// new sourcePrefixes wire field is consumed and produces a +// multi-source group in one shot (no client-side merging needed). +func TestGroupPeerRulesUsesSourcePrefixesWhenPresent(t *testing.T) { + srcs := [][]byte{ + netiputil.EncodeAddr(netip.MustParseAddr("10.0.0.1")), + netiputil.EncodeAddr(netip.MustParseAddr("10.0.0.2")), + netiputil.EncodeAddr(netip.MustParseAddr("10.0.0.3")), + } + rules := []*mgmProto.FirewallRule{ + { + PolicyID: []byte("policy-A"), + SourcePrefixes: srcs, + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "443", + }, + } + + groups, denyErr, err := groupPeerRules(rules) + require.NoError(t, denyErr) + require.NoError(t, err) + require.Len(t, groups, 1) + require.Len(t, groups[0].sources, 3) +} + +// TestGroupPeerRulesActionSeparates verifies the obvious: accept +// and drop rules with the same selector don't merge. +func TestGroupPeerRulesActionSeparates(t *testing.T) { + rules := []*mgmProto.FirewallRule{ + { + PolicyID: []byte("policy-A"), + PeerIP: "10.0.0.1", + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "443", + }, + { + PolicyID: []byte("policy-A"), + PeerIP: "10.0.0.1", + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_DROP, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "443", + }, + } + + groups, denyErr, err := groupPeerRules(rules) + require.NoError(t, denyErr) + require.NoError(t, err) + require.Len(t, groups, 2) +} + +// failingDeleteFirewall wraps a real firewall.Manager and forces the +// next N DeleteFilterRule calls to fail. Used to verify that the acl +// manager retains rules whose deletion was rejected by the backend, +// so they get retried on the next ApplyFiltering pass instead of +// becoming orphans. +type failingDeleteFirewall struct { + fwmgr.Manager + failCount int +} + +func (f *failingDeleteFirewall) DeleteFilterRule(r fwmgr.Rule) error { + if f.failCount > 0 { + f.failCount-- + return errors.New("simulated delete failure") + } + return f.Manager.DeleteFilterRule(r) +} + +// TestApplyFilteringRetainsRulesOnDeleteFailure verifies that a +// transient DeleteFilterRule error doesn't make the acl manager +// forget about a rule. The rule must remain in peerRulesPairs so the +// next ApplyFiltering pass attempts the delete again. +func TestApplyFilteringRetainsRulesOnDeleteFailure(t *testing.T) { + t.Setenv("NB_WG_KERNEL_DISABLED", "true") + t.Setenv(firewall.EnvForceUserspaceFirewall, "true") + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ifaceMock := mocks.NewMockIFaceMapper(ctrl) + ifaceMock.EXPECT().IsUserspaceBind().Return(true).AnyTimes() + ifaceMock.EXPECT().SetFilter(gomock.Any()) + network := netip.MustParsePrefix("172.0.0.1/32") + ifaceMock.EXPECT().Name().Return("lo").AnyTimes() + ifaceMock.EXPECT().Address().Return(wgaddr.Address{IP: network.Addr(), Network: network}).AnyTimes() + ifaceMock.EXPECT().GetWGDevice().Return(nil).AnyTimes() + + realFW, err := firewall.NewFirewall(ifaceMock, nil, flowLogger, false, iface.DefaultMTU) + require.NoError(t, err) + defer func() { require.NoError(t, realFW.Close(nil)) }() + + fw := &failingDeleteFirewall{Manager: realFW} + acl := NewDefaultManager(fw) + + // First pass: install a rule. + netmap1 := &mgmProto.NetworkMap{ + FirewallRules: []*mgmProto.FirewallRule{ + { + PolicyID: []byte("policy-A"), + PeerIP: "10.0.0.1", + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_DROP, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "22", + }, + }, + FirewallRulesIsEmpty: false, + } + acl.ApplyFiltering(netmap1, false) + require.Equal(t, 1, len(acl.peerRulesPairs), "rule should be installed") + + // Second pass: remove the rule from the map. The backend will + // fail the delete; the acl manager must retain the rule. + fw.failCount = 1 + netmap2 := &mgmProto.NetworkMap{FirewallRules: nil, FirewallRulesIsEmpty: true} + acl.ApplyFiltering(netmap2, false) + require.Equal(t, 1, len(acl.peerRulesPairs), + "rule must be retained when DeleteFilterRule fails so it gets retried") + + // Third pass: same map, backend no longer fails. The rule + // should now succeed in being removed. + acl.ApplyFiltering(netmap2, false) + require.Equal(t, 0, len(acl.peerRulesPairs), "retry should succeed") +} diff --git a/client/internal/acl/id/id.go b/client/internal/acl/id/id.go index 23451453e..952403bbd 100644 --- a/client/internal/acl/id/id.go +++ b/client/internal/acl/id/id.go @@ -5,18 +5,18 @@ import ( "encoding/hex" "fmt" "net/netip" + "slices" "strconv" "github.com/netbirdio/netbird/client/firewall/manager" ) -type RuleID string +// RuleID aliases manager.RuleID so existing nbid.RuleID references +// keep working while the canonical type lives in the firewall package. +type RuleID = manager.RuleID -func (r RuleID) ID() string { - return string(r) -} - -func GenerateRouteRuleKey( +// GenerateRuleID returns a deterministic content hash identifying a filter rule. +func GenerateRuleID( sources []netip.Prefix, destination manager.Network, proto manager.Protocol, @@ -24,6 +24,7 @@ func GenerateRouteRuleKey( dPort *manager.Port, action manager.Action, ) RuleID { + sources = slices.Clone(sources) manager.SortPrefixes(sources) h := sha256.New() diff --git a/client/internal/acl/legacy_fallback_test.go b/client/internal/acl/legacy_fallback_test.go new file mode 100644 index 000000000..00ca013f3 --- /dev/null +++ b/client/internal/acl/legacy_fallback_test.go @@ -0,0 +1,75 @@ +package acl + +import ( + "net/netip" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/netbirdio/netbird/client/firewall" + fwmgr "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/internal/acl/mocks" + mgmProto "github.com/netbirdio/netbird/shared/management/proto" +) + +// sourcesRecordingFirewall wraps a real firewall.Manager and records +// the source prefixes of every AddFilterRule call. +type sourcesRecordingFirewall struct { + fwmgr.Manager + mu sync.Mutex + sources [][]netip.Prefix +} + +func (f *sourcesRecordingFirewall) AddFilterRule(id []byte, sources []netip.Prefix, destination fwmgr.Network, proto fwmgr.Protocol, sPort, dPort *fwmgr.Port, action fwmgr.Action) (fwmgr.Rule, error) { + f.mu.Lock() + f.sources = append(f.sources, sources) + f.mu.Unlock() + return f.Manager.AddFilterRule(id, sources, destination, proto, sPort, dPort, action) +} + +// TestLegacyManagementFallbackUsesMatchAnySources verifies the +// allow-all fallback for old management servers (empty FirewallRules +// without the FirewallRulesIsEmpty flag) reaches the firewall as /0 +// match-any sources. The fallback rule carries PeerIP 0.0.0.0; if that +// were converted to a host prefix (0.0.0.0/32) it would match nothing +// and all peer traffic would be dropped. +func TestLegacyManagementFallbackUsesMatchAnySources(t *testing.T) { + t.Setenv("NB_WG_KERNEL_DISABLED", "true") + t.Setenv(firewall.EnvForceUserspaceFirewall, "true") + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ifaceMock := mocks.NewMockIFaceMapper(ctrl) + ifaceMock.EXPECT().IsUserspaceBind().Return(true).AnyTimes() + ifaceMock.EXPECT().SetFilter(gomock.Any()) + network := netip.MustParsePrefix("172.0.0.1/32") + ifaceMock.EXPECT().Name().Return("lo").AnyTimes() + ifaceMock.EXPECT().Address().Return(wgaddr.Address{IP: network.Addr(), Network: network}).AnyTimes() + ifaceMock.EXPECT().GetWGDevice().Return(nil).AnyTimes() + + realFW, err := firewall.NewFirewall(ifaceMock, nil, flowLogger, false, iface.DefaultMTU) + require.NoError(t, err) + defer func() { require.NoError(t, realFW.Close(nil)) }() + + fw := &sourcesRecordingFirewall{Manager: realFW} + acl := NewDefaultManager(fw) + + // Old management: no rules and no FirewallRulesIsEmpty flag. + acl.ApplyFiltering(&mgmProto.NetworkMap{FirewallRules: nil, FirewallRulesIsEmpty: false}, false) + + fw.mu.Lock() + defer fw.mu.Unlock() + require.NotEmpty(t, fw.sources, "legacy fallback must install at least one allow-all rule") + for _, sources := range fw.sources { + require.NotEmpty(t, sources) + for _, p := range sources { + assert.Equal(t, 0, p.Bits(), "legacy fallback source %s must be a /0 match-any prefix", p) + } + } +} diff --git a/client/internal/acl/manager.go b/client/internal/acl/manager.go index d9b179457..6c544b345 100644 --- a/client/internal/acl/manager.go +++ b/client/internal/acl/manager.go @@ -1,8 +1,6 @@ package acl import ( - "crypto/md5" - "encoding/hex" "errors" "fmt" "net/netip" @@ -24,6 +22,10 @@ import ( var ErrSourceRangesEmpty = errors.New("sources range is empty") +// ErrNoRuleReturned is returned when the firewall backend reports success +// from AddFilterRule but yields no rule to track. +var ErrNoRuleReturned = errors.New("backend returned no rule") + // Manager is a ACL rules manager type Manager interface { ApplyFiltering(networkMap *mgmProto.NetworkMap, dnsRouteFeatureFlag bool) @@ -32,19 +34,48 @@ type Manager interface { // DefaultManager uses firewall manager to handle type DefaultManager struct { firewall firewall.Manager - ipsetCounter int peerRulesPairs map[id.RuleID][]firewall.Rule - routeRules map[id.RuleID]struct{} + routeRules map[id.RuleID]firewall.Rule previousConfigHash uint64 hasAppliedConfig bool mutex sync.Mutex } +// peerRuleGroup collapses a set of single-source FirewallRules sharing +// the same selector into one multi-source rule to push to the backend. +type peerRuleGroup struct { + direction mgmProto.RuleDirection + action mgmProto.RuleAction + protocol mgmProto.RuleProtocol + port *mgmProto.PortInfo + // legacyPort is used only when PortInfo is empty (old management). + legacyPort string + policyID []byte + sources []netip.Prefix +} + +// peerRuleKey is the comparable selector that decides which single-source +// rules merge into one group. Rules with an equal key collapse into one +// multi-source backend rule. PortInfo is flattened into its scalar fields +// so the key compares by value; policyID keeps policies separate so two +// policies authorizing different peers don't merge under one attribution. +type peerRuleKey struct { + v6 bool + policyID string + direction mgmProto.RuleDirection + action mgmProto.RuleAction + protocol mgmProto.RuleProtocol + legacyPort string + port uint16 + rangeStart uint16 + rangeEnd uint16 +} + func NewDefaultManager(fm firewall.Manager) *DefaultManager { return &DefaultManager{ firewall: fm, peerRulesPairs: make(map[id.RuleID][]firewall.Rule), - routeRules: make(map[id.RuleID]struct{}), + routeRules: make(map[id.RuleID]firewall.Rule), } } @@ -88,11 +119,14 @@ func (d *DefaultManager) ApplyFiltering(networkMap *mgmProto.NetworkMap, dnsRout time.Since(start), total) }() - d.applyPeerACLs(networkMap) + peerErr := d.applyPeerACLs(networkMap) + if peerErr != nil { + log.Errorf("apply peer ACLs: %v", peerErr) + } routeErr := d.applyRouteACLs(networkMap.RoutesFirewallRules, dnsRouteFeatureFlag) if routeErr != nil { - log.Errorf("Failed to apply route ACLs: %v", routeErr) + log.Errorf("apply route ACLs: %v", routeErr) } flushErr := d.firewall.Flush() @@ -104,7 +138,7 @@ func (d *DefaultManager) ApplyFiltering(networkMap *mgmProto.NetworkMap, dnsRout // If applying or flushing failed, leave the previous hash untouched so the // next (possibly identical) update is not skipped and gets a chance to // reconcile the firewall state. - if err == nil && routeErr == nil && flushErr == nil { + if err == nil && peerErr == nil && routeErr == nil && flushErr == nil { d.previousConfigHash = hash d.hasAppliedConfig = true } else { @@ -116,11 +150,11 @@ func (d *DefaultManager) ApplyFiltering(networkMap *mgmProto.NetworkMap, dnsRout // firewall state, so an identical hash means an identical resulting ruleset. func (d *DefaultManager) firewallConfigHash(networkMap *mgmProto.NetworkMap, dnsRouteFeatureFlag bool) (uint64, error) { return hashstructure.Hash(struct { - PeerRules []*mgmProto.FirewallRule - PeerRulesIsEmpty bool - RouteRules []*mgmProto.RouteFirewallRule - RouteRulesIsEmpty bool - DNSRouteFeatureFlag bool + PeerRules []*mgmProto.FirewallRule + PeerRulesIsEmpty bool + RouteRules []*mgmProto.RouteFirewallRule + RouteRulesIsEmpty bool + DNSRouteFeatureFlag bool }{ PeerRules: networkMap.GetFirewallRules(), PeerRulesIsEmpty: networkMap.GetFirewallRulesIsEmpty(), @@ -135,7 +169,7 @@ func (d *DefaultManager) firewallConfigHash(networkMap *mgmProto.NetworkMap, dns }) } -func (d *DefaultManager) applyPeerACLs(networkMap *mgmProto.NetworkMap) { +func (d *DefaultManager) applyPeerACLs(networkMap *mgmProto.NetworkMap) error { rules := networkMap.FirewallRules // if we got empty rules list but management not set networkMap.FirewallRulesIsEmpty flag @@ -144,13 +178,13 @@ func (d *DefaultManager) applyPeerACLs(networkMap *mgmProto.NetworkMap) { log.Warn("this peer is connected to a NetBird Management service with an older version. Allowing all traffic from connected peers") rules = append(rules, &mgmProto.FirewallRule{ - PeerIP: "0.0.0.0", + PeerIP: "0.0.0.0", //nolint:staticcheck Direction: mgmProto.RuleDirection_IN, Action: mgmProto.RuleAction_ACCEPT, Protocol: mgmProto.RuleProtocol_ALL, }, &mgmProto.FirewallRule{ - PeerIP: "0.0.0.0", + PeerIP: "0.0.0.0", //nolint:staticcheck Direction: mgmProto.RuleDirection_OUT, Action: mgmProto.RuleAction_ACCEPT, Protocol: mgmProto.RuleProtocol_ALL, @@ -158,59 +192,167 @@ func (d *DefaultManager) applyPeerACLs(networkMap *mgmProto.NetworkMap) { ) } - newRulePairs := make(map[id.RuleID][]firewall.Rule) - ipsetByRuleSelectors := make(map[string]string) + // Group incoming single-source rules from management by their + // (direction, action, proto, port) selector and merge sources. + // One call to the firewall backend per merged rule. + // A deny we cannot decode would leave its traffic unblocked, so skip + // the whole pass and keep existing rules until the next sync. + groups, denyErr, err := groupPeerRules(rules) + if denyErr != nil { + return fmt.Errorf("decode deny rule sources: %w", denyErr) + } - // TODO: deny rules should be fatal: if a deny rule fails to apply, we must - // roll back all allow rules to avoid a fail-open where allowed traffic bypasses - // the missing deny. Currently we accumulate errors and continue. + newRulePairs := make(map[id.RuleID][]firewall.Rule) var merr *multierror.Error - for _, r := range rules { - // if this rule is member of rule selection with more than DefaultIPsCountForSet - // it's IP address can be used in the ipset for firewall manager which supports it - selector := d.getRuleGroupingSelector(r) - ipsetName, ok := ipsetByRuleSelectors[selector] - if !ok { - d.ipsetCounter++ - ipsetName = fmt.Sprintf("nb%07d", d.ipsetCounter) - ipsetByRuleSelectors[selector] = ipsetName - } - pairID, rulePair, err := d.protoRuleToFirewallRule(r, ipsetName) - if err != nil { - merr = multierror.Append(merr, fmt.Errorf("apply firewall rule: %w", err)) + if err != nil { + merr = multierror.Append(merr, err) + } + + // Apply denies first. A deny that fails to install is a security + // failure (fail-open), so if any deny errors we roll back the + // denies we already installed in this pass and bail out without + // installing any accept. Pre-existing rules stay untouched until + // the next successful pass clears them. + denies, accepts := splitDenyAccept(groups) + if err := d.installPeerGroups(denies, newRulePairs, true); err != nil { + return fmt.Errorf("install deny rules: %w", err) + } + + if err := d.installPeerGroups(accepts, newRulePairs, false); err != nil { + merr = multierror.Append(merr, err) + } + + // Tear down rules that disappeared from the networkmap. Any rule + // the backend refuses to delete stays in our tracking so it gets + // retried on the next ApplyFiltering. Otherwise a transient + // delete failure would leak the rule in the firewall until the + // process exits. + for pairID, rules := range d.peerRulesPairs { + if _, ok := newRulePairs[pairID]; ok { continue } - if len(rulePair) > 0 { - d.peerRulesPairs[pairID] = rulePair - newRulePairs[pairID] = rulePair - } - } - - if merr != nil { - log.Errorf("failed to apply %d peer ACL rule(s): %v", merr.Len(), nberrors.FormatErrorOrNil(merr)) - } - - for pairID, rules := range d.peerRulesPairs { - if _, ok := newRulePairs[pairID]; !ok { - for _, rule := range rules { - if err := d.firewall.DeletePeerRule(rule); err != nil { - log.Errorf("failed to delete peer firewall rule: %v", err) - continue - } + var remaining []firewall.Rule + for _, rule := range rules { + if err := d.firewall.DeleteFilterRule(rule); err != nil { + merr = multierror.Append(merr, fmt.Errorf("delete peer rule, will retry: %w", err)) + remaining = append(remaining, rule) } - delete(d.peerRulesPairs, pairID) + } + if len(remaining) > 0 { + newRulePairs[pairID] = remaining } } d.peerRulesPairs = newRulePairs + + return nberrors.FormatErrorOrNil(merr) +} + +// installPeerGroups applies each group and records the resulting rule +// pairs in newRulePairs. With atomic set (deny rules), a single failure +// rolls back every rule installed in this call and returns, leaving the +// firewall exactly as before: denies are fail-closed and must be applied +// all-or-nothing. With atomic unset (accept rules), failures are +// accumulated and the remaining groups still install, so one malformed +// allow cannot drop every other legitimate allow in the pass. +func (d *DefaultManager) installPeerGroups(groups []*peerRuleGroup, newRulePairs map[id.RuleID][]firewall.Rule, atomic bool) error { + var freshlyInstalled []id.RuleID + var merr *multierror.Error + for _, g := range groups { + pairID, rulePair, err := d.applyPeerGroup(g) + if err != nil { + if atomic { + d.rollbackInstalled(freshlyInstalled) + return fmt.Errorf("apply firewall rule: %w", err) + } + merr = multierror.Append(merr, fmt.Errorf("apply firewall rule: %w", err)) + continue + } + if len(rulePair) == 0 { + continue + } + if _, existed := d.peerRulesPairs[pairID]; !existed { + freshlyInstalled = append(freshlyInstalled, pairID) + } + d.peerRulesPairs[pairID] = rulePair + newRulePairs[pairID] = rulePair + } + return nberrors.FormatErrorOrNil(merr) +} + +func (d *DefaultManager) rollbackInstalled(pairIDs []id.RuleID) { + var merr *multierror.Error + for _, pairID := range pairIDs { + // Keep any rule the backend refuses to delete tracked so it is + // retried on the next ApplyFiltering instead of leaking in the + // firewall with no tracking left to remove it. + var remaining []firewall.Rule + for _, rule := range d.peerRulesPairs[pairID] { + if err := d.firewall.DeleteFilterRule(rule); err != nil { + merr = multierror.Append(merr, fmt.Errorf("rule %s: %w", pairID, err)) + remaining = append(remaining, rule) + } + } + if len(remaining) > 0 { + d.peerRulesPairs[pairID] = remaining + } else { + delete(d.peerRulesPairs, pairID) + } + } + if err := nberrors.FormatErrorOrNil(merr); err != nil { + log.Errorf("rollback peer rules: %v", err) + } +} + +func (d *DefaultManager) applyPeerGroup(g *peerRuleGroup) (id.RuleID, []firewall.Rule, error) { + protocol, err := ConvertToFirewallProtocol(g.protocol) + if err != nil { + return "", nil, fmt.Errorf("skipping firewall rule: %w", err) + } + action, err := convertFirewallAction(g.action) + if err != nil { + return "", nil, fmt.Errorf("skipping firewall rule: %w", err) + } + port, err := resolveGroupPort(g) + if err != nil { + return "", nil, err + } + + var fwRule firewall.Rule + switch g.direction { + case mgmProto.RuleDirection_IN: + fwRule, err = d.firewall.AddFilterRule(g.policyID, g.sources, firewall.Network{}, protocol, nil, port, action) + case mgmProto.RuleDirection_OUT: + if d.firewall.IsStateful() { + return "", nil, nil + } + if shouldSkipInvertedRule(protocol, port) { + return "", nil, nil + } + fwRule, err = d.firewall.AddFilterRule(g.policyID, g.sources, firewall.Network{}, protocol, port, nil, action) + default: + return "", nil, errors.New("invalid direction") + } + + if err != nil { + return "", nil, fmt.Errorf("add firewall rule: %w", err) + } + if fwRule == nil { + return "", nil, fmt.Errorf("add firewall rule: %w", ErrNoRuleReturned) + } + + // Derive the pair id from the backend rule, like the route path: + // the backend dedups identical content, so two policies authorizing + // the same flow resolve to the same id and a single backing rule. + return fwRule.ID(), []firewall.Rule{fwRule}, nil } func (d *DefaultManager) applyRouteACLs(rules []*mgmProto.RouteFirewallRule, dynamicResolver bool) error { - newRouteRules := make(map[id.RuleID]struct{}, len(rules)) + newRouteRules := make(map[id.RuleID]firewall.Rule, len(rules)) var merr *multierror.Error - // Apply new rules - firewall manager will return existing rule ID if already present + // Apply new rules - firewall manager will return the existing rule if already present for _, rule := range rules { - id, err := d.applyRouteACL(rule, dynamicResolver) + addedRule, err := d.applyRouteACL(rule, dynamicResolver) if err != nil { if errors.Is(err, ErrSourceRangesEmpty) { log.Debugf("skipping empty sources rule with destination %s: %v", rule.Destination, err) @@ -219,16 +361,18 @@ func (d *DefaultManager) applyRouteACLs(rules []*mgmProto.RouteFirewallRule, dyn } continue } - newRouteRules[id] = struct{}{} + newRouteRules[addedRule.ID()] = addedRule } - // Clean up old firewall rules - for id := range d.routeRules { - if _, exists := newRouteRules[id]; !exists { - if err := d.firewall.DeleteRouteRule(id); err != nil { - merr = multierror.Append(merr, fmt.Errorf("delete route rule: %w", err)) - } - // implicitly deleted from the map + // Tear down old route rules; retain ones the backend refused so a + // transient failure doesn't leave orphaned rules in the firewall. + for ruleID, rule := range d.routeRules { + if _, exists := newRouteRules[ruleID]; exists { + continue + } + if err := d.firewall.DeleteFilterRule(rule); err != nil { + merr = multierror.Append(merr, fmt.Errorf("delete route rule, will retry: %w", err)) + newRouteRules[ruleID] = rule } } @@ -236,102 +380,202 @@ func (d *DefaultManager) applyRouteACLs(rules []*mgmProto.RouteFirewallRule, dyn return nberrors.FormatErrorOrNil(merr) } -func (d *DefaultManager) applyRouteACL(rule *mgmProto.RouteFirewallRule, dynamicResolver bool) (id.RuleID, error) { +func (d *DefaultManager) applyRouteACL(rule *mgmProto.RouteFirewallRule, dynamicResolver bool) (firewall.Rule, error) { if len(rule.SourceRanges) == 0 { - return "", ErrSourceRangesEmpty + return nil, ErrSourceRangesEmpty } var sources []netip.Prefix for _, sourceRange := range rule.SourceRanges { source, err := netip.ParsePrefix(sourceRange) if err != nil { - return "", fmt.Errorf("parse source range: %w", err) + return nil, fmt.Errorf("parse source range: %w", err) } - sources = append(sources, source) + sources = append(sources, firewall.UnmapPrefix(source)) } destination, err := determineDestination(rule, dynamicResolver, sources) if err != nil { - return "", fmt.Errorf("determine destination: %w", err) + return nil, fmt.Errorf("determine destination: %w", err) } - protocol, err := convertToFirewallProtocol(rule.Protocol) + protocol, err := ConvertToFirewallProtocol(rule.Protocol) if err != nil { - return "", fmt.Errorf("invalid protocol: %w", err) + return nil, fmt.Errorf("invalid protocol: %w", err) } action, err := convertFirewallAction(rule.Action) if err != nil { - return "", fmt.Errorf("invalid action: %w", err) + return nil, fmt.Errorf("invalid action: %w", err) } dPorts := convertPortInfo(rule.PortInfo) - addedRule, err := d.firewall.AddRouteFiltering(rule.PolicyID, sources, destination, protocol, nil, dPorts, action) + addedRule, err := d.firewall.AddFilterRule(rule.PolicyID, sources, destination, protocol, nil, dPorts, action) if err != nil { - return "", fmt.Errorf("add route rule: %w", err) + return nil, fmt.Errorf("add route rule: %w", err) + } + if addedRule == nil { + return nil, fmt.Errorf("add route rule: %w", ErrNoRuleReturned) } - return id.RuleID(addedRule.ID()), nil + return addedRule, nil } -func (d *DefaultManager) protoRuleToFirewallRule( - r *mgmProto.FirewallRule, - ipsetName string, -) (id.RuleID, []firewall.Rule, error) { - ip, err := extractRuleIP(r) - if err != nil { - return "", nil, err +// splitDenyAccept partitions groups by action so denies can be +// applied before accepts. Order within each bucket is preserved. +func splitDenyAccept(groups []*peerRuleGroup) (denies, accepts []*peerRuleGroup) { + for _, g := range groups { + if g.action == mgmProto.RuleAction_DROP { + denies = append(denies, g) + } else { + accepts = append(accepts, g) + } + } + return denies, accepts +} + +// groupPeerRules merges single-source rules sharing a selector into +// multi-source groups. It splits source-decode failures by action: +// denyErr is non-nil when a deny rule could not be decoded, which is a +// fail-open risk the caller must treat as fatal for the pass; err +// carries the tolerable accept-rule failures the caller can log and +// continue past. +func groupPeerRules(rules []*mgmProto.FirewallRule) (groups []*peerRuleGroup, denyErr error, err error) { + var denyMerr, acceptMerr *multierror.Error + byKey := make(map[peerRuleKey]*peerRuleGroup) + order := make([]peerRuleKey, 0) + + for _, r := range rules { + srcs, decErr := extractRuleSources(r) + if decErr != nil { + if r.Action == mgmProto.RuleAction_DROP { + denyMerr = multierror.Append(denyMerr, decErr) + } else { + acceptMerr = multierror.Append(acceptMerr, decErr) + } + continue + } + // A single FirewallRule normally carries one address family, but + // split by family defensively: each backend keys a rule to one + // family and would mismatch sources of the other, so a group's + // sources must never span families. + v4, v6 := splitPrefixesByFamily(srcs) + for _, sub := range []struct { + isV6 bool + sources []netip.Prefix + }{{false, v4}, {true, v6}} { + if len(sub.sources) == 0 { + continue + } + key := ruleGroupKey(r, sub.isV6) + g, ok := byKey[key] + if !ok { + g = &peerRuleGroup{ + direction: r.Direction, + action: r.Action, + protocol: r.Protocol, + port: r.PortInfo, + legacyPort: r.Port, + policyID: r.PolicyID, + } + byKey[key] = g + order = append(order, key) + } + g.sources = append(g.sources, sub.sources...) + } } - protocol, err := convertToFirewallProtocol(r.Protocol) - if err != nil { - return "", nil, fmt.Errorf("skipping firewall rule: %s", err) + out := make([]*peerRuleGroup, 0, len(order)) + for _, k := range order { + out = append(out, byKey[k]) + } + return out, nberrors.FormatErrorOrNil(denyMerr), nberrors.FormatErrorOrNil(acceptMerr) +} + +func prefixIsV6(p netip.Prefix) bool { + return p.Addr().Is6() && !p.Addr().Is4In6() +} + +// splitPrefixesByFamily partitions prefixes into IPv4 and IPv6 groups. +func splitPrefixesByFamily(prefixes []netip.Prefix) (v4, v6 []netip.Prefix) { + for _, p := range prefixes { + if prefixIsV6(p) { + v6 = append(v6, p) + } else { + v4 = append(v4, p) + } + } + return v4, v6 +} + +// ruleGroupKey builds the selector key for a rule. v6 must reflect the +// rule's source family: mgmt emits one rule per family and mixing them +// would break ICMP-variant selection in uspfilter. +func ruleGroupKey(r *mgmProto.FirewallRule, v6 bool) peerRuleKey { + k := peerRuleKey{ + v6: v6, + policyID: string(r.PolicyID), + direction: r.Direction, + action: r.Action, + protocol: r.Protocol, + legacyPort: r.Port, + } + if pi := r.PortInfo; pi != nil { + k.port = uint16(pi.GetPort()) + if rng := pi.GetRange(); rng != nil { + k.rangeStart = uint16(rng.GetStart()) + k.rangeEnd = uint16(rng.GetEnd()) + } + } + return k +} + +// extractRuleSources returns all source prefixes the rule applies to. +// New management populates sourcePrefixes; older management sets PeerIP. +func extractRuleSources(r *mgmProto.FirewallRule) ([]netip.Prefix, error) { + if len(r.SourcePrefixes) > 0 { + out := make([]netip.Prefix, 0, len(r.SourcePrefixes)) + for _, raw := range r.SourcePrefixes { + addr, err := netiputil.DecodeAddr(raw) + if err != nil { + return nil, fmt.Errorf("decode source prefix: %w", err) + } + out = append(out, netip.PrefixFrom(addr.Unmap(), addr.Unmap().BitLen())) + } + return out, nil } - action, err := convertFirewallAction(r.Action) + peerIP := r.PeerIP //nolint:staticcheck // PeerIP is the legacy source field for old management servers + addr, err := netip.ParseAddr(peerIP) if err != nil { - return "", nil, fmt.Errorf("skipping firewall rule: %s", err) + return nil, fmt.Errorf("parse peer IP %q: %w", peerIP, err) } + addr = addr.Unmap() + // An unspecified PeerIP means "any peer" (legacy management + // allow-all fallback); only a /0 prefix matches any source in the + // backends, a full-length prefix would match nothing. + if addr.IsUnspecified() { + return []netip.Prefix{netip.PrefixFrom(addr, 0)}, nil + } + return []netip.Prefix{netip.PrefixFrom(addr, addr.BitLen())}, nil +} - var port *firewall.Port - if !portInfoEmpty(r.PortInfo) { - port = convertPortInfo(r.PortInfo) - } else if r.Port != "" { - // old version of management, single port - value, err := strconv.Atoi(r.Port) +func resolveGroupPort(g *peerRuleGroup) (*firewall.Port, error) { + if !portInfoEmpty(g.port) { + return convertPortInfo(g.port), nil + } + if g.legacyPort != "" { + value, err := strconv.ParseUint(g.legacyPort, 10, 16) if err != nil { - return "", nil, fmt.Errorf("invalid port: %w", err) + return nil, fmt.Errorf("invalid port: %w", err) } - port = &firewall.Port{ + return &firewall.Port{ Values: []uint16{uint16(value)}, - } + }, nil } - - ruleID := d.getPeerRuleID(ip, protocol, int(r.Direction), port, action) - if rulesPair, ok := d.peerRulesPairs[ruleID]; ok { - return ruleID, rulesPair, nil - } - - var rules []firewall.Rule - switch r.Direction { - case mgmProto.RuleDirection_IN: - rules, err = d.addInRules(r.PolicyID, ip, protocol, port, action, ipsetName) - case mgmProto.RuleDirection_OUT: - if d.firewall.IsStateful() { - return "", nil, nil - } - // return traffic for outbound connections if firewall is stateless - rules, err = d.addOutRules(r.PolicyID, ip, protocol, port, action, ipsetName) - default: - return "", nil, fmt.Errorf("invalid direction, skipping firewall rule") - } - - if err != nil { - return "", nil, err - } - - return ruleID, rules, nil + // nolint:nilnil // a nil port legitimately means "no port restriction" + return nil, nil } func portInfoEmpty(portInfo *mgmProto.PortInfo) bool { @@ -350,85 +594,9 @@ func portInfoEmpty(portInfo *mgmProto.PortInfo) bool { } } -func (d *DefaultManager) addInRules( - id []byte, - ip netip.Addr, - protocol firewall.Protocol, - port *firewall.Port, - action firewall.Action, - ipsetName string, -) ([]firewall.Rule, error) { - rule, err := d.firewall.AddPeerFiltering(id, ip.AsSlice(), protocol, nil, port, action, ipsetName) - if err != nil { - return nil, fmt.Errorf("add firewall rule: %w", err) - } - - return rule, nil -} - -func (d *DefaultManager) addOutRules( - id []byte, - ip netip.Addr, - protocol firewall.Protocol, - port *firewall.Port, - action firewall.Action, - ipsetName string, -) ([]firewall.Rule, error) { - if shouldSkipInvertedRule(protocol, port) { - return nil, nil - } - - rule, err := d.firewall.AddPeerFiltering(id, ip.AsSlice(), protocol, port, nil, action, ipsetName) - if err != nil { - return nil, fmt.Errorf("add firewall rule: %w", err) - } - - return rule, nil -} - -// getPeerRuleID returns unique ID for the rule based on its parameters. -func (d *DefaultManager) getPeerRuleID( - ip netip.Addr, - proto firewall.Protocol, - direction int, - port *firewall.Port, - action firewall.Action, -) id.RuleID { - idStr := ip.String() + string(proto) + strconv.Itoa(direction) + strconv.Itoa(int(action)) - if port != nil { - idStr += port.String() - } - - return id.RuleID(hex.EncodeToString(md5.New().Sum([]byte(idStr)))) -} - -// getRuleGroupingSelector takes all rule properties except IP address to build selector -func (d *DefaultManager) getRuleGroupingSelector(rule *mgmProto.FirewallRule) string { - return fmt.Sprintf("%v:%v:%v:%s:%v", strconv.Itoa(int(rule.Direction)), rule.Action, rule.Protocol, rule.Port, rule.PortInfo) -} - - -// extractRuleIP extracts the peer IP from a firewall rule. -// If sourcePrefixes is populated (new management), decode the first entry and use its address. -// Otherwise fall back to the deprecated PeerIP string field (old management). -func extractRuleIP(r *mgmProto.FirewallRule) (netip.Addr, error) { - if len(r.SourcePrefixes) > 0 { - addr, err := netiputil.DecodeAddr(r.SourcePrefixes[0]) - if err != nil { - return netip.Addr{}, fmt.Errorf("decode source prefix: %w", err) - } - return addr.Unmap(), nil - } - - //nolint:staticcheck // PeerIP used for backward compatibility with old management - addr, err := netip.ParseAddr(r.PeerIP) - if err != nil { - return netip.Addr{}, fmt.Errorf("invalid IP address, skipping firewall rule") - } - return addr.Unmap(), nil -} - -func convertToFirewallProtocol(protocol mgmProto.RuleProtocol) (firewall.Protocol, error) { +// ConvertToFirewallProtocol maps a management rule protocol to the +// firewall protocol type. +func ConvertToFirewallProtocol(protocol mgmProto.RuleProtocol) (firewall.Protocol, error) { switch protocol { case mgmProto.RuleProtocol_TCP: return firewall.ProtocolTCP, nil diff --git a/client/internal/acl/manager_test.go b/client/internal/acl/manager_test.go index 968654ae9..19bef9058 100644 --- a/client/internal/acl/manager_test.go +++ b/client/internal/acl/manager_test.go @@ -5,11 +5,12 @@ import ( "net/netip" "testing" - "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "github.com/netbirdio/netbird/client/firewall" + fwmanager "github.com/netbirdio/netbird/client/firewall/manager" "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/internal/acl/mocks" @@ -77,9 +78,9 @@ func TestDefaultManager(t *testing.T) { }) t.Run("add extra rules", func(t *testing.T) { - existedPairs := map[string]struct{}{} + existedPairs := map[fwmanager.RuleID]struct{}{} for id := range acl.peerRulesPairs { - existedPairs[id.ID()] = struct{}{} + existedPairs[id] = struct{}{} } // remove first rule @@ -87,7 +88,7 @@ func TestDefaultManager(t *testing.T) { networkMap.FirewallRules = append( networkMap.FirewallRules, &mgmProto.FirewallRule{ - PeerIP: "10.93.0.3", + PeerIP: "10.93.0.3", //nolint:staticcheck Direction: mgmProto.RuleDirection_IN, Action: mgmProto.RuleAction_DROP, Protocol: mgmProto.RuleProtocol_ICMP, @@ -106,7 +107,7 @@ func TestDefaultManager(t *testing.T) { // check that old rule was removed previousCount := 0 for id := range acl.peerRulesPairs { - if _, ok := existedPairs[id.ID()]; ok { + if _, ok := existedPairs[id]; ok { previousCount++ } } @@ -556,12 +557,12 @@ func TestApplyFilteringSkipsUnchangedConfig(t *testing.T) { func buildNetworkMap(peerRules, routeRules int) *mgmProto.NetworkMap { nm := &mgmProto.NetworkMap{ - FirewallRulesIsEmpty: peerRules == 0, + FirewallRulesIsEmpty: peerRules == 0, RoutesFirewallRulesIsEmpty: routeRules == 0, } for i := range peerRules { nm.FirewallRules = append(nm.FirewallRules, &mgmProto.FirewallRule{ - PeerIP: fmt.Sprintf("10.%d.%d.%d", i>>16&0xff, i>>8&0xff, i&0xff), + PeerIP: fmt.Sprintf("10.%d.%d.%d", i>>16&0xff, i>>8&0xff, i&0xff), //nolint:staticcheck Direction: mgmProto.RuleDirection_IN, Action: mgmProto.RuleAction_ACCEPT, Protocol: mgmProto.RuleProtocol_TCP, diff --git a/client/internal/acl/mocks/iface_mapper.go b/client/internal/acl/mocks/iface_mapper.go index 95d5a2c58..f8cca1c2d 100644 --- a/client/internal/acl/mocks/iface_mapper.go +++ b/client/internal/acl/mocks/iface_mapper.go @@ -7,7 +7,7 @@ package mocks import ( reflect "reflect" - gomock "github.com/golang/mock/gomock" + gomock "go.uber.org/mock/gomock" wgdevice "golang.zx2c4.com/wireguard/device" "github.com/netbirdio/netbird/client/iface/device" diff --git a/client/internal/auth/auth.go b/client/internal/auth/auth.go index 51f56b644..939df3a21 100644 --- a/client/internal/auth/auth.go +++ b/client/internal/auth/auth.go @@ -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 }) @@ -351,11 +362,13 @@ func (a *Auth) setSystemInfoFlags(info *system.Info) { a.config.BlockLANAccess, a.config.BlockInbound, a.config.DisableIPv6, + a.config.SyncMessageVersion, a.config.EnableSSHRoot, a.config.EnableSSHSFTP, a.config.EnableSSHLocalPortForwarding, a.config.EnableSSHRemotePortForwarding, a.config.DisableSSHAuth, + a.config.RemoteJobsAllowed, ) } diff --git a/client/internal/auth/device_flow.go b/client/internal/auth/device_flow.go index 8d90fb82f..3592e589d 100644 --- a/client/internal/auth/device_flow.go +++ b/client/internal/auth/device_flow.go @@ -299,11 +299,21 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn UseIDToken: d.providerConfig.UseIDToken, } - err = isValidAccessToken(tokenInfo.GetTokenToUse(), d.providerConfig.Audience) + err = validateTokenAudience(tokenInfo.GetTokenToUse(), d.providerConfig.Audience) if err != nil { return TokenInfo{}, fmt.Errorf("validate access token failed with error: %v", err) } + // Same as the PKCE flow: the account the token belongs to is what + // callers store to send back as the login_hint. Without it a client + // driven through the device flow — Android TV and tvOS — never binds + // an account to its profile and every later login goes out blind. + if email, err := parseEmailFromIDToken(tokenInfo.IDToken); err != nil { + log.Warnf("failed to parse email from ID token: %v", err) + } else { + tokenInfo.Email = email + } + log.Infof("device flow: user authorization confirmed after %d polls in %s", polls, time.Since(start).Round(time.Second)) return tokenInfo, err } diff --git a/client/internal/auth/oauth.go b/client/internal/auth/oauth.go index a50a2ce6f..91329c98b 100644 --- a/client/internal/auth/oauth.go +++ b/client/internal/auth/oauth.go @@ -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 } diff --git a/client/internal/auth/pkce_flow.go b/client/internal/auth/pkce_flow.go index d0df2b122..be64cc6a8 100644 --- a/client/internal/auth/pkce_flow.go +++ b/client/internal/auth/pkce_flow.go @@ -306,7 +306,7 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo, audience = p.providerConfig.ClientID } - if err := isValidAccessToken(tokenInfo.GetTokenToUse(), audience); err != nil { + if err := validateTokenAudience(tokenInfo.GetTokenToUse(), audience); err != nil { return TokenInfo{}, fmt.Errorf("authentication failed: invalid access token - %w", err) } @@ -320,6 +320,11 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo, return tokenInfo, nil } +// parseEmailFromIDToken extracts the email (or name) claim from an ID token +// without verifying its signature. The value is best-effort and used only as a +// UX convenience (login hint prefill and display); it never drives an +// authorization decision. The authoritative identity is established server-side +// from the signature-verified token. func parseEmailFromIDToken(token string) (string, error) { parts := strings.Split(token, ".") if len(parts) < 2 { diff --git a/client/internal/auth/sessionwatch/watcher.go b/client/internal/auth/sessionwatch/watcher.go index e75a7022e..e685c28d0 100644 --- a/client/internal/auth/sessionwatch/watcher.go +++ b/client/internal/auth/sessionwatch/watcher.go @@ -24,11 +24,7 @@ import ( ) const ( - // Skew tolerates a small clock difference between the management - // server and this peer before treating a deadline as "in the past". - // Slightly above typical NTP drift; tight enough that the UI doesn't - // paint a stale expiry as if it were valid. - Skew = 30 * time.Second + maxPastHorizon = 30 * 24 * time.Hour // maxDeadlineHorizon caps how far in the future an accepted deadline // can sit. A timestamp beyond this is almost certainly a protocol @@ -57,7 +53,7 @@ var ( ErrDeadlineTooFarFuture = errors.New("session deadline too far in the future") // ErrDeadlineInPast is returned by Update when the supplied deadline - // is more than Skew in the past. + // is more than maxPastHorizon in the past. ErrDeadlineInPast = errors.New("session deadline in the past") ) @@ -66,15 +62,14 @@ var ( // for deadline change/clear, PublishEvent for the two warnings); tests pass // a fake recorder so the same surface is observable without an engine. // -// The watcher is the single owner of the deadline propagated to the -// recorder: every set, clear, sanity-check rejection and Close routes the -// value through SetSessionExpiresAt, so the SubscribeStatus snapshot the UI -// reads can never drift from the watcher's timer state. (SetSessionExpiresAt -// fans out its own state-change notification, so no separate notify is -// needed.) The recorder is server-scoped and outlives this engine-scoped -// watcher — without the Close-time clear a teardown (Down, or the Down+Up of -// a profile switch) would leave the next session showing the previous one's -// stale "expires in" value. +// While the watcher runs, it owns the deadline propagated to the recorder: +// every set, clear and sanity-check rejection routes the value through +// SetSessionExpiresAt, so the SubscribeStatus snapshot the UI reads can +// never drift from the watcher's timer state. (SetSessionExpiresAt fans +// out its own state-change notification, so no separate notify is needed.) +// The recorder is server-scoped and outlives this engine-scoped watcher; +// Close deliberately leaves the recorder value in place so transient engine +// restarts don't blank it — the client run loop clears it on real teardown. // // PublishEvent's signature mirrors peer.Status.PublishEvent: the watcher // composes the metadata internally so the wire format (MetaSession*) is @@ -135,10 +130,13 @@ func NewWithLeads(lead, final time.Duration, recorder StatusRecorder) *Watcher { // was disabled). // // Same-value updates are no-ops. A different non-zero value cancels any -// pending timer, resets the "already fired" guard, and arms a new one. +// pending timer, resets the "already fired" guards, and — when the +// deadline lies in the future — arms fresh warning timers. A deadline +// already in the past (within maxPastHorizon) is recorded as-is with no +// timers: the session has expired and consumers render it that way. // // Returns one of the sentinel Err* values when the deadline fails the -// sanity checks (pre-epoch, far future, or in the past beyond Skew). +// sanity checks (pre-epoch, far future, or past beyond maxPastHorizon). // In every error case the watcher first clears its state so it stays // consistent with what the caller will push into its other sinks (e.g. // applySessionDeadline forces a zero deadline into the status recorder @@ -163,7 +161,7 @@ func (w *Watcher) Update(deadline time.Time) error { case deadline.After(now.Add(maxDeadlineHorizon)): w.clearLocked() return fmt.Errorf("%w: %v", ErrDeadlineTooFarFuture, deadline) - case deadline.Before(now.Add(-Skew)): + case deadline.Before(now.Add(-maxPastHorizon)): w.clearLocked() return fmt.Errorf("%w: %v (now=%v)", ErrDeadlineInPast, deadline, now) } @@ -183,7 +181,9 @@ func (w *Watcher) Update(deadline time.Time) error { w.finalFiredAt = time.Time{} w.dismissedAt = time.Time{} - w.armTimerLocked(deadline) + if deadline.After(now) { + w.armTimerLocked(deadline) + } recorder := w.recorder w.mu.Unlock() if recorder != nil { @@ -227,30 +227,25 @@ func (w *Watcher) Dismiss() { log.Infof("auth session final-warning dismissed for deadline %s", w.current.Format(time.RFC3339)) } -// Close stops any pending timer and drops the deadline on the status -// recorder. Update calls after Close are ignored. Clearing the recorder -// here is what keeps a teardown (Down, or the Down+Up of a profile switch) -// from leaving the next session showing this one's stale "expires in" -// value — the recorder is server-scoped and outlives this engine-scoped -// watcher, so nothing else drops the anchor on teardown. +// Close stops any pending timer. Update calls after Close are ignored. +// The recorder keeps its deadline: the watcher is engine-scoped and closes +// on every engine restart (network change, sleep/wake, stream errors) +// while the SSO deadline stays valid across those, so clearing here would +// blank the UI's "expires in" row on every transient reconnect. The +// client run loop clears the server-scoped recorder when it exits for +// real (Down, profile switch, permanent login failure). func (w *Watcher) Close() { w.mu.Lock() + defer w.mu.Unlock() if w.closed { - w.mu.Unlock() return } w.closed = true w.stopTimerLocked() - hadDeadline := !w.current.IsZero() w.current = time.Time{} w.firedAt = time.Time{} w.finalFiredAt = time.Time{} w.dismissedAt = time.Time{} - recorder := w.recorder - w.mu.Unlock() - if recorder != nil && hadDeadline { - recorder.SetSessionExpiresAt(time.Time{}) - } } // clearLocked drops the tracked deadline and notifies the recorder so diff --git a/client/internal/auth/sessionwatch/watcher_test.go b/client/internal/auth/sessionwatch/watcher_test.go index da2b6add6..4b49a94b6 100644 --- a/client/internal/auth/sessionwatch/watcher_test.go +++ b/client/internal/auth/sessionwatch/watcher_test.go @@ -224,11 +224,13 @@ func TestNewDeadlineCancelsPriorTimer(t *testing.T) { func TestRefreshAfterFireArmsNewWarning(t *testing.T) { r := &fakeRecorder{} - lead := 30 * time.Millisecond + lead := 150 * time.Millisecond w := newWatcher(lead, r) defer w.Close() - first := time.Now().Add(50 * time.Millisecond) + // Warning fires ~20ms in; the deadline itself stays 150ms away so the + // replacement below lands well before it. + first := time.Now().Add(170 * time.Millisecond) _ = w.Update(first) // Wait for stateChange + warning of the first cycle. @@ -306,7 +308,29 @@ func TestUpdateRejectsTooFarFuture(t *testing.T) { } } -func TestUpdateInPastClearsDeadline(t *testing.T) { +func TestUpdateRecentPastRecordedAsExpired(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(50*time.Millisecond, r) + defer w.Close() + + d := time.Now().Add(-1 * time.Hour) + if err := w.Update(d); err != nil { + t.Fatalf("recent-past Update should succeed, got %v", err) + } + if !w.Deadline().Equal(d) { + t.Fatalf("expected deadline to be recorded, got %v want %v", w.Deadline(), d) + } + if got := r.deadline(); !got.Equal(d) { + t.Fatalf("recorder deadline = %v, want %v", got, d) + } + + time.Sleep(80 * time.Millisecond) + if n := countWhere(r.snapshot(), func(e event) bool { return e.kind == publish }); n != 0 { + t.Fatalf("no warning events may fire for an already-past deadline, got %+v", r.snapshot()) + } +} + +func TestUpdateAncientPastRejected(t *testing.T) { r := &fakeRecorder{} w := newWatcher(50*time.Millisecond, r) defer w.Close() @@ -318,12 +342,12 @@ func TestUpdateInPastClearsDeadline(t *testing.T) { // Drain the stateChange from the seed. waitForEvents(t, r, 1) - err := w.Update(time.Now().Add(-1 * time.Hour)) + err := w.Update(time.Now().Add(-31 * 24 * time.Hour)) if !errors.Is(err, ErrDeadlineInPast) { t.Fatalf("want ErrDeadlineInPast, got %v", err) } if !w.Deadline().IsZero() { - t.Fatalf("in-past update must clear the deadline, got %v", w.Deadline()) + t.Fatalf("rejected ancient-past update must clear the deadline, got %v", w.Deadline()) } events := waitForEvents(t, r, 2) if events[1].kind != stateChange { @@ -331,39 +355,25 @@ func TestUpdateInPastClearsDeadline(t *testing.T) { } } -func TestUpdateWithinSkewAccepted(t *testing.T) { - r := &fakeRecorder{} - w := newWatcher(50*time.Millisecond, r) - defer w.Close() - - // 5 seconds in the past is within the 30s Skew tolerance — accept it. - d := time.Now().Add(-5 * time.Second) - if err := w.Update(d); err != nil { - t.Fatalf("within-skew Update should succeed, got %v", err) - } - if !w.Deadline().Equal(d) { - t.Fatalf("expected deadline to be applied, got %v want %v", w.Deadline(), d) - } -} - func TestCloseSilencesUpdates(t *testing.T) { r := &fakeRecorder{} w := newWatcher(50*time.Millisecond, r) w.Close() - _ = w.Update(time.Now().Add(time.Hour)) - - time.Sleep(20 * time.Millisecond) + if err := w.Update(time.Now().Add(time.Hour)); err != nil { + t.Fatalf("Update after Close: want nil, got %v", err) + } if got := r.snapshot(); len(got) != 0 { t.Fatalf("expected no events after Close, got %+v", got) } } -// TestCloseClearsRecorderDeadline pins the profile-switch fix: a watcher -// holding a live deadline must zero the recorder on Close so the next -// engine's watcher (and the UI reading the shared server-scoped recorder) -// doesn't start out showing the previous session's stale "expires in". -func TestCloseClearsRecorderDeadline(t *testing.T) { +// TestCloseKeepsRecorderDeadline pins the reconnect-flap fix: the watcher +// closes on every engine restart (network change, sleep/wake) while the +// SSO deadline stays valid across those, so Close must leave the +// server-scoped recorder's value in place. The client run loop clears the +// recorder when it exits for real. +func TestCloseKeepsRecorderDeadline(t *testing.T) { r := &fakeRecorder{} w := newWatcher(time.Hour, r) @@ -377,8 +387,8 @@ func TestCloseClearsRecorderDeadline(t *testing.T) { w.Close() - if got := r.deadline(); !got.IsZero() { - t.Fatalf("recorder deadline after Close = %v, want zero", got) + if got := r.deadline(); !got.Equal(d) { + t.Fatalf("recorder deadline after Close = %v, want %v", got, d) } } diff --git a/client/internal/auth/util.go b/client/internal/auth/util.go index 31c81d701..1800584a2 100644 --- a/client/internal/auth/util.go +++ b/client/internal/auth/util.go @@ -20,14 +20,26 @@ func randomBytesInHex(count int) (string, error) { return hex.EncodeToString(buf), nil } -// isValidAccessToken is a simple validation of the access token -func isValidAccessToken(token string, audience string) error { +// validateTokenAudience checks that the token is a well-formed JWT whose +// audience claim matches the expected audience. +// +// It does NOT verify the token's cryptographic signature and therefore must not +// be treated as an authenticity check. The token is obtained by the client +// directly from the IdP token endpoint over TLS, and its signature is verified +// server-side by the management server against the IdP's JWKS +// (see shared/auth/jwt/validator.go). This function is only a client-side +// sanity check that the returned token targets the expected audience. +func validateTokenAudience(token string, audience string) error { if token == "" { return fmt.Errorf("token received is empty") } - encodedClaims := strings.Split(token, ".")[1] - claimsString, err := base64.RawURLEncoding.DecodeString(encodedClaims) + parts := strings.Split(token, ".") + if len(parts) != 3 { + return fmt.Errorf("token is not a well-formed JWT") + } + + claimsString, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return err } diff --git a/client/internal/auth/util_test.go b/client/internal/auth/util_test.go new file mode 100644 index 000000000..7f225bb86 --- /dev/null +++ b/client/internal/auth/util_test.go @@ -0,0 +1,108 @@ +package auth + +import ( + "encoding/base64" + "encoding/json" + "testing" +) + +// makeJWT builds an unsigned JWT-shaped string (header.payload.signature) with +// the given claims payload. The signature part is arbitrary because +// validateTokenAudience intentionally does not verify it. +func makeJWT(t *testing.T, claims map[string]interface{}) string { + t.Helper() + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`)) + payloadBytes, err := json.Marshal(claims) + if err != nil { + t.Fatalf("marshal claims: %v", err) + } + payload := base64.RawURLEncoding.EncodeToString(payloadBytes) + return header + "." + payload + ".unverified-signature" +} + +func TestValidateTokenAudience(t *testing.T) { + tests := []struct { + name string + token string + audience string + wantErr bool + }{ + { + name: "empty token", + token: "", + audience: "netbird", + wantErr: true, + }, + { + name: "not a JWT - no dots", + token: "notajwt", + audience: "netbird", + wantErr: true, + }, + { + name: "not a JWT - two parts only", + token: "header.payload", + audience: "netbird", + wantErr: true, + }, + { + name: "matching string audience", + token: makeJWT(t, map[string]interface{}{"aud": "netbird"}), + audience: "netbird", + wantErr: false, + }, + { + name: "mismatching string audience", + token: makeJWT(t, map[string]interface{}{"aud": "other"}), + audience: "netbird", + wantErr: true, + }, + { + name: "matching audience in array", + token: makeJWT(t, map[string]interface{}{"aud": []interface{}{"other", "netbird"}}), + audience: "netbird", + wantErr: false, + }, + { + name: "mismatching audience array", + token: makeJWT(t, map[string]interface{}{"aud": []interface{}{"a", "b"}}), + audience: "netbird", + wantErr: true, + }, + { + name: "missing audience claim", + token: makeJWT(t, map[string]interface{}{"sub": "user"}), + audience: "netbird", + wantErr: true, + }, + { + name: "invalid base64 payload", + token: "header.!!!not-base64!!!.sig", + audience: "netbird", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := validateTokenAudience(tc.token, tc.audience) + if tc.wantErr && err == nil { + t.Fatalf("expected error, got nil") + } + if !tc.wantErr && err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) + } +} + +// TestValidateTokenAudienceNoPanic guards the regression where a non-empty +// token without the JWT dot structure caused an index-out-of-range panic. +func TestValidateTokenAudienceNoPanic(t *testing.T) { + inputs := []string{"a", ".", "a.", "aaaa", "no-dots-here"} + for _, in := range inputs { + if err := validateTokenAudience(in, "netbird"); err == nil { + t.Fatalf("expected error for malformed token %q, got nil", in) + } + } +} diff --git a/client/internal/conn_mgr.go b/client/internal/conn_mgr.go index 77d1e6ca5..8b01eabcf 100644 --- a/client/internal/conn_mgr.go +++ b/client/internal/conn_mgr.go @@ -2,6 +2,7 @@ package internal import ( "context" + "maps" "os" "strconv" "sync" @@ -14,6 +15,7 @@ import ( "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/peerstore" "github.com/netbirdio/netbird/route" + mgmProto "github.com/netbirdio/netbird/shared/management/proto" ) // lazyForce is the resolved local decision for lazy connections, layered above the @@ -34,94 +36,97 @@ const ( // - Handling connection establishment based on peer signaling // // The implementation is not thread-safe; it is protected by engine.syncMsgMux. +// The only exception is ActivatePeer, which is safe for concurrent use so the +// DNS warm-up path can call it without contending on the engine mutex. type ConnMgr struct { - peerStore *peerstore.Store - statusRecorder *peer.Status - iface lazyconn.WGIface - force lazyForce - rosenpassEnabled bool + peerStore *peerstore.Store + statusRecorder *peer.Status + iface lazyconn.WGIface + force lazyForce + // remoteLazyEnabled caches the account-wide lazy feature flag from management. + // It is the default for peers that do not carry a per-peer lazy hint. + remoteLazyEnabled bool lazyConnMgr *manager.Manager + // lazyConnMgrMu guards the lazyConnMgr pointer for readers outside the + // engine loop (ActivatePeer). Writers hold it in addition to + // engine.syncMsgMux; all other reads stay under engine.syncMsgMux only. + lazyConnMgrMu sync.RWMutex + + // reconcileRoutedIPs re-applies a peer's routed allowed IPs after its lazy wake endpoint is + // (re)armed (Mode A at arm time). Injected by the engine; nil disables the reconcile. + reconcileRoutedIPs func(peerKey string) error + + // appliedExcludeList is the exclude set last handed to the lazy manager, kept so an + // unchanged set on the next sync skips the O(n) reconciliation. + appliedExcludeList map[string]bool wg sync.WaitGroup lazyCtx context.Context lazyCtxCancel context.CancelFunc } +// SetRoutedIPsReconciler injects the callback used to re-apply a peer's routed allowed IPs when +// its lazy wake endpoint is (re)armed. Must be called before the lazy manager starts. +func (e *ConnMgr) SetRoutedIPsReconciler(fn func(peerKey string) error) { + e.reconcileRoutedIPs = fn +} + func NewConnMgr(engineConfig *EngineConfig, statusRecorder *peer.Status, peerStore *peerstore.Store, iface lazyconn.WGIface) *ConnMgr { e := &ConnMgr{ - peerStore: peerStore, - statusRecorder: statusRecorder, - iface: iface, - force: resolveLazyForce(engineConfig.LazyConnection), - rosenpassEnabled: engineConfig.RosenpassEnabled, + peerStore: peerStore, + statusRecorder: statusRecorder, + iface: iface, + force: resolveLazyForce(engineConfig.LazyConnection), } return e } -// Start initializes the connection manager. It starts the lazy connection manager when a -// local override forces it on; with no local override it waits for the management feature flag. +// Start initializes the connection manager. The lazy connection manager always runs so that +// per-peer lazy defaults (e.g. proxy peers) work even when the account flag is off; the +// account flag and the local override decide the default lazy state per peer (see +// PeerLazyDefault). Rosenpass peers stay lazy-capable too: their connections just never idle +// on their own, since rosenpass rekey traffic keeps them active. func (e *ConnMgr) Start(ctx context.Context) { if e.lazyConnMgr != nil { log.Errorf("lazy connection manager is already started") return } - switch e.force { - case lazyForceOff: - log.Infof("lazy connection manager is disabled by local override (%s or MDM policy)", lazyconn.EnvLazyConn) - e.statusRecorder.UpdateLazyConnection(false) - return - case lazyForceNone: - log.Infof("lazy connection manager is managed by the management feature flag") - e.statusRecorder.UpdateLazyConnection(false) - return - } - - if e.rosenpassEnabled { - log.Warnf("rosenpass connection manager is enabled, lazy connection manager will not be started") - e.statusRecorder.UpdateLazyConnection(false) - return - } - e.initLazyManager(ctx) - e.statusRecorder.UpdateLazyConnection(true) + e.statusRecorder.UpdateLazyConnection(e.PeerLazyDefault(mgmProto.LazyState_LazyStateDefault)) } -// UpdatedRemoteFeatureFlag is called when the remote feature flag is updated. -// If enabled, it initializes the lazy connection manager and start it. Do not need to call Start() again. -// If disabled, then it closes the lazy connection manager and open the connections to all peers. -func (e *ConnMgr) UpdatedRemoteFeatureFlag(ctx context.Context, enabled bool) error { - // a local override (NB_LAZY_CONN or local config) takes precedence over management - if e.force != lazyForceNone { - return nil +// UpdatedRemoteFeatureFlag caches the account-wide lazy feature flag. The manager itself is +// not started or stopped here; the per-sync exclude-list reconciliation moves normal peers +// between the lazy and always-active sets when the flag flips. +func (e *ConnMgr) UpdatedRemoteFeatureFlag(_ context.Context, enabled bool) error { + e.remoteLazyEnabled = enabled + if e.isStartedWithLazyMgr() { + e.statusRecorder.UpdateLazyConnection(e.PeerLazyDefault(mgmProto.LazyState_LazyStateDefault)) + } + return nil +} + +// PeerLazyDefault reports whether a peer should be lazy. The local override +// (NB_LAZY_CONN/MDM) wins over everything; without a local override the +// management per-peer state applies (LazyStateLazy/Eager force the decision), +// and LazyStateDefault follows the account-wide flag. +func (e *ConnMgr) PeerLazyDefault(state mgmProto.LazyState) bool { + switch e.force { + case lazyForceOn: + return true + case lazyForceOff: + return false } - if enabled { - // if the lazy connection manager is already started, do not start it again - if e.lazyConnMgr != nil { - return nil - } - - if e.rosenpassEnabled { - log.Infof("rosenpass connection manager is enabled, lazy connection manager will not be started") - e.statusRecorder.UpdateLazyConnection(false) - return nil - } - - log.Infof("lazy connection manager is enabled by the management feature flag") - e.initLazyManager(ctx) - e.statusRecorder.UpdateLazyConnection(true) - return e.addPeersToLazyConnManager() - } else { - if e.lazyConnMgr == nil { - e.statusRecorder.UpdateLazyConnection(false) - return nil - } - log.Infof("lazy connection manager is disabled by management feature flag") - e.closeManager(ctx) - e.statusRecorder.UpdateLazyConnection(false) - return nil + switch state { + case mgmProto.LazyState_LazyStateLazy: + return true + case mgmProto.LazyState_LazyStateEager: + return false + default: + return e.remoteLazyEnabled } } @@ -141,6 +146,13 @@ func (e *ConnMgr) SetExcludeList(ctx context.Context, peerIDs map[string]bool) { return } + // The exclude set is recomputed every sync but rarely changes; skip the O(n) + // store lookups and reconciliation when it matches what was already applied. + if maps.Equal(peerIDs, e.appliedExcludeList) { + return + } + e.appliedExcludeList = maps.Clone(peerIDs) + excludedPeers := make([]lazyconn.PeerConfig, 0, len(peerIDs)) for peerID := range peerIDs { @@ -176,12 +188,16 @@ func (e *ConnMgr) SetExcludeList(ctx context.Context, peerIDs map[string]bool) { } } -func (e *ConnMgr) AddPeerConn(ctx context.Context, peerKey string, conn *peer.Conn) (exists bool) { +// AddPeerConn registers a peer connection. permanent requests an always-active connection +// (the peer belongs to the exclude set: a forwarder, or a peer that is not lazy by policy). +// Non-permanent peers are handed to the lazy manager. The subsequent SetExcludeList call +// reconciles membership for existing peers across flag flips. +func (e *ConnMgr) AddPeerConn(ctx context.Context, peerKey string, conn *peer.Conn, permanent bool) (exists bool) { if success := e.peerStore.AddPeerConn(peerKey, conn); !success { return true } - if !e.isStartedWithLazyMgr() { + if !e.isStartedWithLazyMgr() || permanent { if err := conn.Open(ctx); err != nil { conn.Log.Errorf("failed to open connection: %v", err) } @@ -238,12 +254,20 @@ func (e *ConnMgr) RemovePeerConn(peerKey string) { conn.Log.Infof("removed peer from lazy conn manager") } +// ActivatePeer wakes an idle lazy connection. Unlike the rest of ConnMgr it is +// safe for concurrent use: the lazy manager pointer is read under lazyConnMgrMu +// and the manager itself is internally synchronized, so callers outside the +// engine loop (DNS warm-up) do not need engine.syncMsgMux. func (e *ConnMgr) ActivatePeer(ctx context.Context, conn *peer.Conn) { - if !e.isStartedWithLazyMgr() { + e.lazyConnMgrMu.RLock() + lazyConnMgr := e.lazyConnMgr + started := lazyConnMgr != nil && e.lazyCtxCancel != nil + e.lazyConnMgrMu.RUnlock() + if !started { return } - if found := e.lazyConnMgr.ActivatePeer(conn.GetKey()); found { + if found := lazyConnMgr.ActivatePeer(conn.GetKey()); found { if err := conn.Open(ctx); err != nil { conn.Log.Errorf("failed to open connection: %v", err) } @@ -268,16 +292,26 @@ func (e *ConnMgr) Close() { e.lazyCtxCancel() e.wg.Wait() + + e.lazyConnMgrMu.Lock() e.lazyConnMgr = nil + e.lazyConnMgrMu.Unlock() + + e.appliedExcludeList = nil } func (e *ConnMgr) initLazyManager(engineCtx context.Context) { cfg := manager.Config{ InactivityThreshold: inactivityThresholdEnv(), + ReconcileAllowedIPs: e.reconcileRoutedIPs, } - e.lazyConnMgr = manager.NewManager(cfg, engineCtx, e.peerStore, e.iface) + e.lazyConnMgrMu.Lock() + e.lazyConnMgr = manager.NewManager(cfg, engineCtx, e.peerStore, e.iface) e.lazyCtx, e.lazyCtxCancel = context.WithCancel(engineCtx) + e.lazyConnMgrMu.Unlock() + + e.appliedExcludeList = nil e.wg.Add(1) go func() { @@ -286,43 +320,6 @@ func (e *ConnMgr) initLazyManager(engineCtx context.Context) { }() } -func (e *ConnMgr) addPeersToLazyConnManager() error { - peers := e.peerStore.PeersPubKey() - lazyPeerCfgs := make([]lazyconn.PeerConfig, 0, len(peers)) - for _, peerID := range peers { - var peerConn *peer.Conn - var exists bool - if peerConn, exists = e.peerStore.PeerConn(peerID); !exists { - log.Warnf("failed to find peer conn for peerID: %s", peerID) - continue - } - - lazyPeerCfg := lazyconn.PeerConfig{ - PublicKey: peerID, - AllowedIPs: peerConn.WgConfig().AllowedIps, - PeerConnID: peerConn.ConnID(), - Log: peerConn.Log, - } - lazyPeerCfgs = append(lazyPeerCfgs, lazyPeerCfg) - } - - return e.lazyConnMgr.AddActivePeers(lazyPeerCfgs) -} - -func (e *ConnMgr) closeManager(ctx context.Context) { - if e.lazyConnMgr == nil { - return - } - - e.lazyCtxCancel() - e.wg.Wait() - e.lazyConnMgr = nil - - for _, peerID := range e.peerStore.PeersPubKey() { - e.peerStore.PeerConnOpen(ctx, peerID) - } -} - func (e *ConnMgr) isStartedWithLazyMgr() bool { return e.lazyConnMgr != nil && e.lazyCtxCancel != nil } @@ -352,11 +349,20 @@ func inactivityThresholdEnv() *time.Duration { return nil } - parsedMinutes, err := strconv.Atoi(envValue) - if err != nil || parsedMinutes <= 0 { - return nil + // Documented format: a Go duration such as "30m" or "1h". + if d, err := time.ParseDuration(envValue); err == nil { + if d <= 0 { + return nil + } + return &d } - d := time.Duration(parsedMinutes) * time.Minute - return &d + // Backwards compatibility: a bare integer used to be interpreted as minutes. + if parsedMinutes, err := strconv.Atoi(envValue); err == nil && parsedMinutes > 0 { + d := time.Duration(parsedMinutes) * time.Minute + return &d + } + + log.Warnf("invalid %s value %q: expected a Go duration such as 30m or 1h", lazyconn.EnvInactivityThreshold, envValue) + return nil } diff --git a/client/internal/conn_mgr_test.go b/client/internal/conn_mgr_test.go index 5e2c53e35..e3723b5ff 100644 --- a/client/internal/conn_mgr_test.go +++ b/client/internal/conn_mgr_test.go @@ -1,10 +1,22 @@ package internal import ( + "context" + "net" + "net/netip" "os" + "sync" "testing" + "time" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + + "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/internal/lazyconn" + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/peerstore" + "github.com/netbirdio/netbird/monotime" + mgmProto "github.com/netbirdio/netbird/shared/management/proto" ) func TestResolveLazyForce(t *testing.T) { @@ -38,3 +50,180 @@ func TestResolveLazyForce(t *testing.T) { }) } } + +type mockLazyWGIface struct{} + +func (mockLazyWGIface) RemovePeer(string) error { return nil } +func (mockLazyWGIface) UpdatePeer(string, []netip.Prefix, time.Duration, *net.UDPAddr, *wgtypes.Key) error { + return nil +} +func (mockLazyWGIface) IsUserspaceBind() bool { return false } +func (mockLazyWGIface) Address() wgaddr.Address { return wgaddr.Address{} } +func (mockLazyWGIface) LastActivities() map[string]monotime.Time { return nil } +func (mockLazyWGIface) MTU() uint16 { return 1280 } + +// TestConnMgr_ActivatePeerConcurrentWithLifecycle exercises ActivatePeer from +// non-engine goroutines (the DNS warm-up path) racing the manager lifecycle, +// which stays on the engine loop. Run with -race: it fails if ActivatePeer +// still requires engine.syncMsgMux for safety. +func TestConnMgr_ActivatePeerConcurrentWithLifecycle(t *testing.T) { + t.Setenv(lazyconn.EnvLazyConn, "on") + + status := peer.NewRecorder("https://mgm") + store := peerstore.NewConnStore() + connMgr := NewConnMgr(&EngineConfig{}, status, store, mockLazyWGIface{}) + + conn := newTestPeerConn(t, "peerA") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + connMgr.Start(ctx) + + done := make(chan struct{}) + var wg sync.WaitGroup + for range 4 { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-done: + return + default: + connMgr.ActivatePeer(ctx, conn) + } + } + }() + } + + // Let the activators spin against the started manager, then tear it down + // underneath them and let them spin against the stopped manager. + time.Sleep(100 * time.Millisecond) + connMgr.Close() + time.Sleep(50 * time.Millisecond) + + close(done) + wg.Wait() +} + +func TestInactivityThresholdEnv(t *testing.T) { + tests := []struct { + name string + val string + want *time.Duration + }{ + {name: "unset", val: "", want: nil}, + {name: "go duration minutes", val: "30m", want: durPtr(30 * time.Minute)}, + {name: "go duration hours", val: "1h", want: durPtr(time.Hour)}, + {name: "go duration seconds", val: "90s", want: durPtr(90 * time.Second)}, + {name: "bare integer is minutes (backwards compat)", val: "5", want: durPtr(5 * time.Minute)}, + {name: "zero duration", val: "0s", want: nil}, + {name: "zero integer", val: "0", want: nil}, + {name: "negative duration", val: "-5m", want: nil}, + {name: "garbage", val: "abc", want: nil}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(lazyconn.EnvInactivityThreshold, tc.val) + got := inactivityThresholdEnv() + switch { + case tc.want == nil && got != nil: + t.Fatalf("want nil, got %v", *got) + case tc.want != nil && got == nil: + t.Fatalf("want %v, got nil", *tc.want) + case tc.want != nil && *got != *tc.want: + t.Fatalf("want %v, got %v", *tc.want, *got) + } + }) + } +} + +func TestPeerLazyDefault(t *testing.T) { + tests := []struct { + name string + force lazyForce + remoteEnabled bool + state mgmProto.LazyState + want bool + }{ + {name: "force on wins over eager state", force: lazyForceOn, state: mgmProto.LazyState_LazyStateEager, want: true}, + {name: "force off wins over lazy state", force: lazyForceOff, remoteEnabled: true, state: mgmProto.LazyState_LazyStateLazy, want: false}, + {name: "none, default, account off -> active", force: lazyForceNone, state: mgmProto.LazyState_LazyStateDefault, want: false}, + {name: "none, default, account on -> lazy", force: lazyForceNone, remoteEnabled: true, state: mgmProto.LazyState_LazyStateDefault, want: true}, + {name: "none, lazy state, account off -> lazy", force: lazyForceNone, state: mgmProto.LazyState_LazyStateLazy, want: true}, + {name: "none, eager state, account on -> active", force: lazyForceNone, remoteEnabled: true, state: mgmProto.LazyState_LazyStateEager, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := &ConnMgr{force: tt.force, remoteLazyEnabled: tt.remoteEnabled} + if got := e.PeerLazyDefault(tt.state); got != tt.want { + t.Fatalf("PeerLazyDefault(%v) = %v, want %v", tt.state, got, tt.want) + } + }) + } +} + +func durPtr(d time.Duration) *time.Duration { return &d } + +// TestToExcludedLazyPeers covers the per-peer lazy classification (proxy vs +// normal, across the force/account-flag matrix). Forwarder-target exclusion is +// covered by TestToExcludedLazyPeers_ForwardTarget. +func TestToExcludedLazyPeers(t *testing.T) { + const ( + normalKey = "normal" + lazyKey = "lazy-state" + eagerKey = "eager-state" + ) + + peers := []*mgmProto.RemotePeerConfig{ + {WgPubKey: normalKey, AllowedIps: []string{"100.64.0.1/32"}}, + {WgPubKey: lazyKey, AllowedIps: []string{"100.64.0.2/32"}, LazyState: mgmProto.LazyState_LazyStateLazy}, + {WgPubKey: eagerKey, AllowedIps: []string{"100.64.0.3/32"}, LazyState: mgmProto.LazyState_LazyStateEager}, + } + + tests := []struct { + name string + force lazyForce + remoteEnabled bool + want map[string]bool + }{ + { + name: "account off: lazy-state peer lazy, normal + eager active", + force: lazyForceNone, remoteEnabled: false, + want: map[string]bool{normalKey: true, eagerKey: true}, + }, + { + name: "account on: only eager-state peer active", + force: lazyForceNone, remoteEnabled: true, + want: map[string]bool{eagerKey: true}, + }, + { + name: "force off: everything active", + force: lazyForceOff, remoteEnabled: true, + want: map[string]bool{normalKey: true, lazyKey: true, eagerKey: true}, + }, + { + name: "force on: nothing active", + force: lazyForceOn, remoteEnabled: false, + want: map[string]bool{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := &Engine{connMgr: &ConnMgr{force: tt.force, remoteLazyEnabled: tt.remoteEnabled}} + got := e.toExcludedLazyPeers(peers) + + if len(got) != len(tt.want) { + t.Fatalf("toExcludedLazyPeers() = %v, want %v", got, tt.want) + } + for k := range tt.want { + if !got[k] { + t.Fatalf("expected peer %s excluded, got %v", k, got) + } + } + }) + } +} diff --git a/client/internal/connect.go b/client/internal/connect.go index c2fc2fd73..88d829d2f 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -34,9 +34,11 @@ import ( "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/internal/statemanager" "github.com/netbirdio/netbird/client/internal/stdnet" + "github.com/netbirdio/netbird/client/internal/tunnelnotifier" "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/netevents" cProto "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/ssh" sshconfig "github.com/netbirdio/netbird/client/ssh/config" @@ -69,18 +71,31 @@ type ConnectClient struct { updateManager *updater.Manager persistSyncResponse bool + + // netMgr gates every reconnection loop on OS-reported network + // availability and sweeps connections on network change. + netMgr *netevents.Manager +} + +// ConnectClientOption configures optional ConnectClient behavior. +type ConnectClientOption func(*ConnectClient) + +// WithNetEvents injects the OS network event handling. +func WithNetEvents(events *netevents.Manager) ConnectClientOption { + return func(c *ConnectClient) { c.netMgr = events } } 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{}), @@ -88,6 +103,10 @@ func NewConnectClient( statusRecorder: statusRecorder, engineMutex: sync.Mutex{}, } + for _, opt := range opts { + opt(c) + } + return c } func (c *ConnectClient) SetUpdateManager(um *updater.Manager) { @@ -112,11 +131,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, @@ -136,10 +158,13 @@ func (c *ConnectClient) RunOniOS( // Set GC percent to 5% to reduce memory usage as iOS only allows 50MB of memory for the extension. debug.SetGCPercent(5) + notifier := tunnelnotifier.New(networkChangeListener, dnsManager) + defer notifier.Close() + mobileDependency := MobileDependency{ FileDescriptor: fileDescriptor, - NetworkChangeListener: networkChangeListener, - DnsManager: dnsManager, + NetworkChangeListener: notifier, + DnsManager: notifier, StateFilePath: stateFilePath, TempDir: cacheDir, } @@ -217,7 +242,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan wrapErr := state.Wrap myPrivateKey, err := wgtypes.ParseKey(c.config.PrivateKey) if err != nil { - log.Errorf("failed parsing Wireguard key %s: [%s]", c.config.PrivateKey, err.Error()) + log.Errorf("failed parsing Wireguard key: %s", err) return wrapErr(err) } @@ -257,13 +282,23 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan log.Errorf("failed to clean up temporary installer file: %v", err) } - defer c.statusRecorder.ClientStop() + defer func() { + c.statusRecorder.SetSessionExpiresAt(time.Time{}) + c.statusRecorder.ClientStop() + }() operation := func() error { // if context cancelled we not start new backoff cycle if c.ctx.Err() != nil { return nil } + // suspend connection attempts while the OS reports no usable network + if waited, err := c.netMgr.Wait(c.ctx); err != nil { + return nil + } else if waited { + backOff.Reset() + } + state.Set(StatusConnecting) engineCtx, cancel := context.WithCancel(c.ctx) @@ -275,7 +310,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.WithNetEvents(c.netMgr)) if err != nil { // On daemon shutdown / Down() the parent context is cancelled // and the dial fails with "context canceled". Wrapping that @@ -350,7 +386,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.netMgr) if err != nil { log.Error(err) return wrapErr(err) @@ -386,7 +422,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.WithNetEvents(c.netMgr)) c.statusRecorder.SetRelayMgr(relayManager) if len(relayURLs) > 0 { if token != nil { @@ -414,6 +451,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan UpdateManager: c.updateManager, ClientMetrics: c.clientMetrics, MetricsCtx: c.ctx, + NetMgr: c.netMgr, }, mobileDependency) engine.SetSyncResponsePersistence(c.persistSyncResponse) c.engine = engine @@ -470,6 +508,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) @@ -604,6 +652,7 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf RosenpassEnabled: config.RosenpassEnabled, RosenpassPermissive: config.RosenpassPermissive, ServerSSHAllowed: util.ReturnBoolWithDefaultTrue(config.ServerSSHAllowed), + RemoteJobsAllowed: util.ReturnBoolWithDefaultFalse(config.RemoteJobsAllowed), EnableSSHRoot: config.EnableSSHRoot, EnableSSHSFTP: config.EnableSSHSFTP, EnableSSHLocalPortForwarding: config.EnableSSHLocalPortForwarding, @@ -618,6 +667,7 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf BlockLANAccess: config.BlockLANAccess, BlockInbound: config.BlockInbound, DisableIPv6: config.DisableIPv6, + SyncMessageVersion: config.SyncMessageVersion, LazyConnection: lazyconn.ParseState(config.LazyConnection), @@ -662,7 +712,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, netMgr *netevents.Manager) (*signal.GrpcClient, error) { var sigTLSEnabled bool if wtConfig.Signal.Protocol == mgmProto.HostConfig_HTTPS { sigTLSEnabled = true @@ -670,7 +720,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.WithNetEvents(netMgr)) 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) @@ -693,11 +744,13 @@ func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte, config.BlockLANAccess, config.BlockInbound, config.DisableIPv6, + config.SyncMessageVersion, config.EnableSSHRoot, config.EnableSSHSFTP, config.EnableSSHLocalPortForwarding, config.EnableSSHRemotePortForwarding, config.DisableSSHAuth, + config.RemoteJobsAllowed, ) return client.Login(sysInfo, pubSSHKey, config.DNSLabels) } diff --git a/client/internal/connect_test.go b/client/internal/connect_test.go index c317c88d8..3212e6abf 100644 --- a/client/internal/connect_test.go +++ b/client/internal/connect_test.go @@ -5,65 +5,78 @@ import ( "testing" ) -func Test_freePort(t *testing.T) { - tests := []struct { - name string - port int - want int - shouldMatch bool - }{ - { - name: "when port is 0 use random port", - port: 0, - want: 0, - shouldMatch: false, - }, - { - name: "provided and available", - port: 51821, - want: 51821, - shouldMatch: true, - }, - { - name: "provided and not available", - port: 51830, - want: 51830, - shouldMatch: false, - }, - } - c1, err := net.ListenUDP("udp", &net.UDPAddr{Port: 0}) +// probeFreePort asks the OS for a free UDP port and immediately releases it. +// The returned number is only a hint: nothing stops another process from +// grabbing the same port before the caller gets a chance to bind it. +// +// A hardcoded port number is not an option here: any fixed number can fall +// inside the ephemeral range and be held by an unrelated process on the test +// runner. +func probeFreePort(t *testing.T) int { + t.Helper() + + conn, err := net.ListenUDP("udp", &net.UDPAddr{Port: 0}) if err != nil { - t.Errorf("freePort error = %v", err) + t.Fatalf("failed to bind probe port: %v", err) } - defer func(c1 *net.UDPConn) { - _ = c1.Close() - }(c1) - - if tests[1].port == c1.LocalAddr().(*net.UDPAddr).Port { - tests[1].port++ - tests[1].want++ - } - - tests[2].port = c1.LocalAddr().(*net.UDPAddr).Port - tests[2].want = c1.LocalAddr().(*net.UDPAddr).Port - - for _, tt := range tests { - - t.Run(tt.name, func(t *testing.T) { - got, err := freePort(tt.port) - - if err != nil { - t.Errorf("got an error while getting free port: %v", err) - } - - if tt.shouldMatch && got != tt.want { - t.Errorf("got a different port %v, want %v", got, tt.want) - } - - if !tt.shouldMatch && got == tt.want { - t.Errorf("got the same port %v, want a different port", tt.want) - } - }) - + port := conn.LocalAddr().(*net.UDPAddr).Port + if err := conn.Close(); err != nil { + t.Fatalf("failed to close probe port: %v", err) } + return port +} + +func Test_freePort(t *testing.T) { + t.Run("when port is 0 use random port", func(t *testing.T) { + got, err := freePort(0) + if err != nil { + t.Fatalf("got an error while getting free port: %v", err) + } + if got == 0 { + t.Errorf("got port 0, want a non-zero random port") + } + }) + + t.Run("provided and available", func(t *testing.T) { + const maxAttempts = 5 + + // The probed port is released before freePort binds it, so an + // unrelated process on the test runner can grab it in between, + // making freePort fall back to a different port. Retry with a + // freshly probed port instead of failing on a lost race. + for attempt := 1; attempt <= maxAttempts; attempt++ { + candidate := probeFreePort(t) + + got, err := freePort(candidate) + if err != nil { + t.Fatalf("got an error while getting free port: %v", err) + } + + if got == candidate { + return + } + t.Logf("attempt %d: freePort returned %d instead of the requested %d, retrying", attempt, got, candidate) + } + + t.Fatalf("freePort did not return the requested free port after %d attempts", maxAttempts) + }) + + t.Run("provided and not available", func(t *testing.T) { + busy, err := net.ListenUDP("udp", &net.UDPAddr{Port: 0}) + if err != nil { + t.Fatalf("failed to bind busy port: %v", err) + } + t.Cleanup(func() { + _ = busy.Close() + }) + busyPort := busy.LocalAddr().(*net.UDPAddr).Port + + got, err := freePort(busyPort) + if err != nil { + t.Fatalf("got an error while getting free port: %v", err) + } + if got == busyPort { + t.Errorf("got the same port %v, want a different port", busyPort) + } + }) } diff --git a/client/internal/daemonaddr/identity.go b/client/internal/daemonaddr/identity.go new file mode 100644 index 000000000..b6af515b7 --- /dev/null +++ b/client/internal/daemonaddr/identity.go @@ -0,0 +1,17 @@ +package daemonaddr + +import "strings" + +// CarriesIdentity reports whether the control channel at addr conveys the +// connecting process's identity to the daemon. A Unix socket carries peer +// credentials and a named pipe carries the client's token. Nothing else does, TCP +// included, and there the daemon can authorize a privileged operation for nobody +// at all: see ResolveDaemonAddr, which says as much to anyone still reaching the +// Windows daemon on the address it served before it had a pipe. +// +// A client uses this to tell whether becoming privileged would get it anywhere. +// It answers from the scheme and nothing else, so an address it does not +// recognise counts as carrying no identity. +func CarriesIdentity(addr string) bool { + return strings.HasPrefix(addr, "unix://") || strings.HasPrefix(addr, pipeScheme) +} diff --git a/client/internal/daemonaddr/identity_test.go b/client/internal/daemonaddr/identity_test.go new file mode 100644 index 000000000..2808b5017 --- /dev/null +++ b/client/internal/daemonaddr/identity_test.go @@ -0,0 +1,29 @@ +package daemonaddr + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCarriesIdentity(t *testing.T) { + tests := []struct { + addr string + want bool + }{ + {"unix:///var/run/netbird.sock", true}, + {"unix:///var/run/netbird/default.sock", true}, + {"npipe://netbird", true}, + {`npipe://\\.\pipe\ProtectedPrefix\Administrators\netbird`, true}, + {"tcp://127.0.0.1:41731", false}, + {"tcp://localhost:41731", false}, + {"", false}, + {"/var/run/netbird.sock", false}, + } + + for _, tt := range tests { + t.Run(tt.addr, func(t *testing.T) { + assert.Equal(t, tt.want, CarriesIdentity(tt.addr), "address %q", tt.addr) + }) + } +} diff --git a/client/internal/daemonaddr/owner.go b/client/internal/daemonaddr/owner.go new file mode 100644 index 000000000..c476f9ae6 --- /dev/null +++ b/client/internal/daemonaddr/owner.go @@ -0,0 +1,15 @@ +package daemonaddr + +// DaemonRunsAsSelf reports whether the daemon listening at addr runs as this very +// user. That is what makes an unprivileged daemon authorize this process for the +// changes it otherwise restricts to root or an administrator, so a client can tell +// up front whether those controls are usable instead of letting a save fail. +// +// It is answered from the ownership of the socket or pipe the daemon created, so it +// costs no round trip and needs no cooperation from the daemon. Ownership that +// cannot be read is reported as false, including for a TCP address, so a caller +// reading this as "the daemon would allow it" fails closed. The daemon remains the +// only thing that authorizes anything: this only decides what a client offers. +func DaemonRunsAsSelf(addr string) bool { + return daemonRunsAsSelf(addr) +} diff --git a/client/internal/daemonaddr/owner_unix.go b/client/internal/daemonaddr/owner_unix.go new file mode 100644 index 000000000..493e6528d --- /dev/null +++ b/client/internal/daemonaddr/owner_unix.go @@ -0,0 +1,40 @@ +//go:build !windows + +package daemonaddr + +import ( + "os" + "strings" + "syscall" + + log "github.com/sirupsen/logrus" +) + +// daemonRunsAsSelf compares the owner of the daemon's Unix socket with this +// process's uid. Root is not treated specially here: a root caller is privileged +// on its own merits, and a root-owned socket says nothing about the caller. +func daemonRunsAsSelf(addr string) bool { + path, ok := strings.CutPrefix(addr, "unix://") + if !ok { + return false + } + + info, err := os.Stat(path) + if err != nil { + log.Debugf("stat daemon socket %s: %v", path, err) + return false + } + + // Only a socket says anything about a daemon. A directory or a leftover + // regular file at that path is not one, and reading it as "the daemon runs as + // us" would offer controls the daemon then refuses. + if info.Mode()&os.ModeSocket == 0 { + return false + } + + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return false + } + return stat.Uid == uint32(os.Getuid()) +} diff --git a/client/internal/daemonaddr/owner_unix_test.go b/client/internal/daemonaddr/owner_unix_test.go new file mode 100644 index 000000000..363c7d95d --- /dev/null +++ b/client/internal/daemonaddr/owner_unix_test.go @@ -0,0 +1,62 @@ +//go:build !windows + +package daemonaddr + +import ( + "net" + "os" + "path/filepath" + "testing" +) + +// A socket this user created means the daemon runs as this user, which is the +// rootless case where the daemon delegates its authority to its own identity. +func TestDaemonRunsAsSelf_OwnSocket(t *testing.T) { + path := filepath.Join(t.TempDir(), "netbird.sock") + ln, err := net.Listen("unix", path) + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { + if err := ln.Close(); err != nil { + t.Logf("close listener: %v", err) + } + }) + + if !DaemonRunsAsSelf("unix://" + path) { + t.Error("a socket owned by this user must count as the daemon running as us") + } +} + +// Everything that is not a readable socket of ours has to answer false, because +// the caller reads a true as "the daemon would authorize me". +func TestDaemonRunsAsSelf_FailsClosed(t *testing.T) { + dir := t.TempDir() + + // A socket owned by another user, which is what a root-run daemon looks like + // to an unprivileged client. Only assertable when we are not root ourselves. + rootOwned := "unix:///var/run/netbird.sock" + if _, err := os.Stat("/var/run/netbird.sock"); err == nil && os.Getuid() != 0 { + if DaemonRunsAsSelf(rootOwned) { + t.Error("a socket owned by another user must not count as ours") + } + } + + for name, addr := range map[string]string{ + "missing socket": "unix://" + filepath.Join(dir, "absent.sock"), + "tcp address": "tcp://127.0.0.1:41731", + "named pipe": "npipe://netbird", + "empty": "", + "no scheme": filepath.Join(dir, "absent.sock"), + "directory": "unix://" + dir, + "unknown scheme": "http://localhost:8080", + "scheme only": "unix://", + "relative socket": "unix://netbird.sock", + } { + t.Run(name, func(t *testing.T) { + if DaemonRunsAsSelf(addr) { + t.Errorf("%q must not count as a daemon running as us", addr) + } + }) + } +} diff --git a/client/internal/daemonaddr/owner_windows.go b/client/internal/daemonaddr/owner_windows.go new file mode 100644 index 000000000..1cd2bba15 --- /dev/null +++ b/client/internal/daemonaddr/owner_windows.go @@ -0,0 +1,42 @@ +//go:build windows + +package daemonaddr + +import ( + "context" + "strings" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/ipcauth" +) + +// daemonRunsAsSelf reads the owner of the daemon's pipe. A daemon running as the +// service account owns its pipe as LocalSystem, and an elevated one as +// BUILTIN\Administrators, so only a daemon the user started themselves matches. +func daemonRunsAsSelf(addr string) bool { + name, ok := strings.CutPrefix(addr, pipeScheme) + if !ok { + return false + } + + for _, path := range PipePaths(name) { + // Bounded: this runs on the UI's path for deciding which controls to + // offer, so a pipe that does not answer promptly must not stall it. A + // timeout leaves the caller unprivileged, which only disables controls. + ctx, cancel := context.WithTimeout(context.Background(), probeTimeout) + conn, err := dialPipe(ctx, path) + cancel() + if err != nil { + continue + } + + owned := ipcauth.PipeOwnedBySelf(conn) + if cerr := conn.Close(); cerr != nil { + log.Debugf("close daemon pipe %s after ownership check: %v", path, cerr) + } + return owned + } + + return false +} diff --git a/client/internal/daemonaddr/pipe.go b/client/internal/daemonaddr/pipe.go new file mode 100644 index 000000000..51815ef5e --- /dev/null +++ b/client/internal/daemonaddr/pipe.go @@ -0,0 +1,103 @@ +package daemonaddr + +import ( + "context" + "net" + "runtime" + "strings" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +const ( + // WindowsPipeAddr is the default daemon address on Windows. A named pipe + // carries the connecting process's token, which loopback TCP does not, so + // it is the only Windows transport on which the daemon can tell who is + // calling it. + WindowsPipeAddr = "npipe://netbird" + + // legacyWindowsAddr is the loopback-TCP address the Windows daemon used + // before named-pipe support. + legacyWindowsAddr = "tcp://127.0.0.1:41731" + + pipeScheme = "npipe://" + + // protectedPrefix is the NPFS namespace in which only LocalSystem and + // members of BUILTIN\Administrators may create a pipe. A daemon running as + // the service account creates its pipe there so that an unprivileged process + // cannot pre-create the name, which would keep the daemon from starting and + // leave callers talking to the squatter. Opening such a pipe needs no + // privilege, so unprivileged clients still reach the daemon. + protectedPrefix = `ProtectedPrefix\Administrators\` +) + +// DialTarget returns the gRPC dial target and transport options for a daemon +// address. The npipe scheme needs a context dialer because gRPC has no +// named-pipe resolver; unix and tcp are handled by gRPC itself. +func DialTarget(addr string) (string, []grpc.DialOption) { + opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())} + + if name, ok := strings.CutPrefix(addr, pipeScheme); ok { + paths := PipePaths(name) + opts = append(opts, grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { + return dialPipePaths(ctx, paths) + })) + return "passthrough:///netbird-daemon-pipe", opts + } + + return strings.TrimPrefix(addr, "tcp://"), opts +} + +// PipePath maps an npipe address name ("netbird", from "npipe://netbird") to a +// Windows named-pipe path (\\.\pipe\netbird). A fully qualified path is left as +// is. +func PipePath(name string) string { + if strings.HasPrefix(name, `\\`) { + return name + } + return `\\.\pipe\` + name +} + +// PipePaths returns the paths a daemon control pipe may live at for an npipe +// address name, in the order both sides must try them: the protected name first, +// then the plain one. +// +// The daemon serves the first it can create, which is the protected name when it +// runs as the service account and the plain one when it runs as an ordinary user, +// as it does in netstack mode. Clients therefore have to try both, and because a +// client cannot tell from the name alone who created the pipe, the plain name is +// only usable once the server's identity has been checked: see +// verifyPipeServer. +// +// A fully qualified path is what the operator asked for and is used as is. +func PipePaths(name string) []string { + if strings.HasPrefix(name, `\\`) { + return []string{name} + } + return []string{PipePath(protectedPrefix + name), PipePath(name)} +} + +// IsProtectedPipePath reports whether a pipe path is in the namespace only an +// administrator or LocalSystem can create in, which is what lets a client trust +// such a pipe from its name alone. +func IsProtectedPipePath(path string) bool { + return strings.HasPrefix(path, `\\.\pipe\`+protectedPrefix) +} + +// MigrateLegacy upgrades the pre-named-pipe Windows daemon address to the named +// pipe, reporting whether it rewrote the address. Existing installs persist the +// daemon address, so without this an upgraded daemon would keep listening on +// loopback TCP, where callers carry no identity and privileged operations would +// have to be refused for everyone. Only the exact legacy default is rewritten: +// a deliberately chosen custom address is left alone. +func MigrateLegacy(addr string) (string, bool) { + return migrateLegacyForOS(runtime.GOOS, addr) +} + +func migrateLegacyForOS(goos, addr string) (string, bool) { + if goos == "windows" && addr == legacyWindowsAddr { + return WindowsPipeAddr, true + } + return addr, false +} diff --git a/client/internal/daemonaddr/pipe_other.go b/client/internal/daemonaddr/pipe_other.go new file mode 100644 index 000000000..04e8e7331 --- /dev/null +++ b/client/internal/daemonaddr/pipe_other.go @@ -0,0 +1,15 @@ +//go:build !windows + +package daemonaddr + +import ( + "context" + "fmt" + "net" +) + +// dialPipePaths is Windows-only: no other platform serves the daemon on a named +// pipe. +func dialPipePaths(context.Context, []string) (net.Conn, error) { + return nil, fmt.Errorf("named pipes are only supported on Windows") +} diff --git a/client/internal/daemonaddr/pipe_test.go b/client/internal/daemonaddr/pipe_test.go new file mode 100644 index 000000000..b9dfd90f1 --- /dev/null +++ b/client/internal/daemonaddr/pipe_test.go @@ -0,0 +1,30 @@ +package daemonaddr + +import ( + "slices" + "testing" +) + +// The protected name must be tried before the plain one on both sides: it is the +// one an unprivileged process cannot create, so preferring it is what keeps a +// squatter from owning the name the service daemon would otherwise use. +func TestPipePaths_PrefersTheProtectedName(t *testing.T) { + got := PipePaths("netbird") + want := []string{ + `\\.\pipe\ProtectedPrefix\Administrators\netbird`, + `\\.\pipe\netbird`, + } + if !slices.Equal(got, want) { + t.Errorf("PipePaths = %q, want %q", got, want) + } +} + +// An operator who passes a full path chose exactly one pipe, so neither side may +// look anywhere else. +func TestPipePaths_QualifiedPathIsUsedAsIs(t *testing.T) { + path := `\\.\pipe\custom-netbird` + got := PipePaths(path) + if !slices.Equal(got, []string{path}) { + t.Errorf("PipePaths = %q, want just %q", got, path) + } +} diff --git a/client/internal/daemonaddr/pipe_windows.go b/client/internal/daemonaddr/pipe_windows.go new file mode 100644 index 000000000..3cd10a6c3 --- /dev/null +++ b/client/internal/daemonaddr/pipe_windows.go @@ -0,0 +1,59 @@ +//go:build windows + +package daemonaddr + +import ( + "context" + "errors" + "fmt" + "net" + + "github.com/Microsoft/go-winio" + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" + + "github.com/netbirdio/netbird/client/internal/ipcauth" +) + +// dialPipePaths connects to the first path that answers with a pipe server this +// client may trust, and returns the last error when none does. +func dialPipePaths(ctx context.Context, paths []string) (net.Conn, error) { + var lastErr error + for _, path := range paths { + conn, err := dialPipe(ctx, path) + if err != nil { + log.Debugf("dial daemon pipe %s: %v", path, err) + lastErr = err + continue + } + + // A pipe in the protected namespace could only have been created by an + // administrator or LocalSystem, so its name is the guarantee. Any other + // name has to be checked, because any local user can create one. + if !IsProtectedPipePath(path) { + if err := ipcauth.PipeServerTrusted(conn); err != nil { + if closeErr := conn.Close(); closeErr != nil { + log.Debugf("close untrusted pipe %s: %v", path, closeErr) + } + lastErr = fmt.Errorf("%s: %w", path, err) + continue + } + } + + return conn, nil + } + + if lastErr == nil { + lastErr = errors.New("no daemon pipe to connect to") + } + return nil, lastErr +} + +// dialPipe connects to the daemon control pipe at SECURITY_IDENTIFICATION. +// winio's plain DialPipe connects at SECURITY_ANONYMOUS, under which the daemon +// cannot read the caller's token at all. Identification lets the daemon read the +// caller's SID and groups without granting it the ability to act as the caller. +func dialPipe(ctx context.Context, path string) (net.Conn, error) { + access := uint32(windows.GENERIC_READ | windows.GENERIC_WRITE) + return winio.DialPipeAccessImpLevel(ctx, path, access, winio.PipeImpLevelIdentification) +} diff --git a/client/internal/daemonaddr/resolve_pipe_other.go b/client/internal/daemonaddr/resolve_pipe_other.go new file mode 100644 index 000000000..1aede8453 --- /dev/null +++ b/client/internal/daemonaddr/resolve_pipe_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package daemonaddr + +// ResolveDaemonAddr is a no-op off Windows, where there is no named-pipe +// default to fall back from. +func ResolveDaemonAddr(addr string) string { + return addr +} diff --git a/client/internal/daemonaddr/resolve_pipe_windows.go b/client/internal/daemonaddr/resolve_pipe_windows.go new file mode 100644 index 000000000..d12ddb15d --- /dev/null +++ b/client/internal/daemonaddr/resolve_pipe_windows.go @@ -0,0 +1,82 @@ +//go:build windows + +package daemonaddr + +import ( + "net" + "strings" + "time" + + "github.com/Microsoft/go-winio" + log "github.com/sirupsen/logrus" +) + +// probeTimeout bounds each transport probe. Both are local, so a daemon that is +// listening answers immediately and one that is not fails immediately. +const probeTimeout = 300 * time.Millisecond + +// ResolveDaemonAddr keeps a client on the named pipe and never silently moves it +// off. When the pipe does not answer it checks the legacy loopback TCP address, so +// a client meeting a daemon that has not restarted since the upgrade can say what +// is wrong, but it does not connect there. +// +// Using that address automatically would be a downgrade the user never asked for: +// any local process can bind 127.0.0.1 while the daemon is not listening, and the +// transport carries no caller identity, so a client that accepted whatever answered +// would hand a setup key, a pre-shared key or an SSO prompt to a local impostor. An +// operator who needs the legacy address during the upgrade window can still pass +// --daemon-addr explicitly, which is a deliberate choice and still refuses the +// privileged operations. +// +// Only the pipe address is resolved. A custom address is left alone, though passing +// --daemon-addr npipe://netbird explicitly is indistinguishable from the default +// here, so it is treated the same way. +func ResolveDaemonAddr(addr string) string { + if addr != WindowsPipeAddr { + return addr + } + + for _, path := range PipePaths("netbird") { + if pipeAvailable(path) { + return addr + } + } + + if tcpAvailable(legacyWindowsAddr) { + log.Warnf("the daemon is not serving %s, but something is listening on the legacy %s. "+ + "Restart the NetBird service so it serves the pipe. That address is not used automatically: "+ + "any local user can bind it and it carries no caller identity, so pass --daemon-addr %s "+ + "explicitly if you accept that", + WindowsPipeAddr, legacyWindowsAddr, legacyWindowsAddr) + } + + return addr +} + +func pipeAvailable(path string) bool { + timeout := probeTimeout + conn, err := winio.DialPipe(path, &timeout) + if err != nil { + return false + } + if err := conn.Close(); err != nil { + log.Debugf("close daemon pipe probe: %v", err) + } + return true +} + +func tcpAvailable(addr string) bool { + host := addr + if _, after, ok := strings.Cut(addr, "://"); ok { + host = after + } + + conn, err := net.DialTimeout("tcp", host, probeTimeout) + if err != nil { + return false + } + if err := conn.Close(); err != nil { + log.Debugf("close daemon TCP probe: %v", err) + } + return true +} diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go index 0e506ccd7..b362ae293 100644 --- a/client/internal/debug/debug.go +++ b/client/internal/debug/debug.go @@ -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,11 +238,17 @@ 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 ( clientLogFile = "client.log" - uiLogFile = "gui-client.log" errorLogFile = "netbird.err" stdoutLogFile = "netbird.out" @@ -248,6 +267,20 @@ type MetricsExporter interface { Export(w io.Writer) error } +// LogOpener opens a log file for inclusion in the bundle. It exists so that log +// files whose path was supplied by an IPC caller can be opened under a check +// the daemon defines, instead of being opened with the daemon's privileges +// unconditionally. +type LogOpener func(path string) (*os.File, error) + +func openLogFile(path string) (*os.File, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open %s: %w", path, err) + } + return f, nil +} + type BundleGenerator struct { anonymizer *anonymize.Anonymizer @@ -257,6 +290,7 @@ type BundleGenerator struct { syncResponse *mgmProto.SyncResponse logPath string uiLogPath string + uiLogOpener LogOpener tempDir string statePath string cpuProfile []byte @@ -267,6 +301,7 @@ type BundleGenerator struct { cliVersion string anonymize bool + anonymizeLevel anonymize.Level includeSystemInfo bool logFileCount uint32 @@ -274,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 } @@ -285,14 +323,20 @@ type GeneratorDependencies struct { SyncResponse *mgmProto.SyncResponse LogPath string UILogPath string // Absolute path to the desktop UI's gui-client.log, reported via RegisterUILog. Empty if no UI registered one. - TempDir string // Directory for temporary bundle zip files. If empty, os.TempDir() is used. - StatePath string // Path to the state file. If empty, the ServiceManager default path is used. - CPUProfile []byte - CapturePath string - RefreshStatus func() - ClientMetrics MetricsExporter - DaemonVersion string - CliVersion string + // UILogOpener opens the UI log and its rotated siblings. The path comes from + // a local IPC caller, so the daemon must not open it with plain os.Open: the + // opener is where the caller's right to that file is enforced. Defaults to + // os.Open, which is only correct where the path is not caller-supplied + // (mobile). + UILogOpener LogOpener + TempDir string // Directory for temporary bundle zip files. If empty, os.TempDir() is used. + StatePath string // Path to the state file. If empty, the ServiceManager default path is used. + CPUProfile []byte + CapturePath string + RefreshStatus func() + ClientMetrics MetricsExporter + DaemonVersion string + CliVersion string } func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGenerator { @@ -302,14 +346,23 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen logFileCount = 1 } + uiLogOpener := deps.UILogOpener + if uiLogOpener == nil { + 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, syncResponse: deps.SyncResponse, logPath: deps.LogPath, uiLogPath: deps.UILogPath, + uiLogOpener: uiLogOpener, tempDir: deps.TempDir, statePath: deps.StatePath, cpuProfile: deps.CPUProfile, @@ -319,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, } @@ -459,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) } @@ -481,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() @@ -636,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)) @@ -650,6 +711,9 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder) if g.internalConfig.ServerSSHAllowed != nil { configContent.WriteString(fmt.Sprintf("ServerSSHAllowed: %v\n", *g.internalConfig.ServerSSHAllowed)) } + if g.internalConfig.RemoteJobsAllowed != nil { + configContent.WriteString(fmt.Sprintf("RemoteJobsAllowed: %v\n", *g.internalConfig.RemoteJobsAllowed)) + } if g.internalConfig.EnableSSHRoot != nil { configContent.WriteString(fmt.Sprintf("EnableSSHRoot: %v\n", *g.internalConfig.EnableSSHRoot)) } @@ -676,6 +740,9 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder) configContent.WriteString(fmt.Sprintf("BlockLANAccess: %v\n", g.internalConfig.BlockLANAccess)) configContent.WriteString(fmt.Sprintf("BlockInbound: %v\n", g.internalConfig.BlockInbound)) configContent.WriteString(fmt.Sprintf("DisableIPv6: %v\n", g.internalConfig.DisableIPv6)) + configContent.WriteString(fmt.Sprintf("LocalMetricsEnabled: %v\n", g.internalConfig.LocalMetricsEnabled)) + configContent.WriteString(fmt.Sprintf("LocalMetricsAddress: %s\n", g.internalConfig.LocalMetricsAddress)) + configContent.WriteString(fmt.Sprintf("SyncMessageVersion: %v\n", g.internalConfig.SyncMessageVersion)) if g.internalConfig.DisableNotifications != nil { configContent.WriteString(fmt.Sprintf("DisableNotifications: %v\n", *g.internalConfig.DisableNotifications)) @@ -925,6 +992,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) } @@ -952,6 +1024,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 @@ -963,6 +1042,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") @@ -995,11 +1095,11 @@ func (g *BundleGenerator) addLogfile() error { logDir := filepath.Dir(g.logPath) - if err := g.addSingleLogfile(g.logPath, clientLogFile); err != nil { + if err := g.addSingleLogfile(openLogFile, g.logPath, clientLogFile); err != nil { return fmt.Errorf("add client log file to zip: %w", err) } - g.addRotatedLogFiles(logDir, clientLogPrefix) + g.addRotatedLogFiles(openLogFile, logDir, clientLogPrefix) stdErrLogPath := filepath.Join(logDir, errorLogFile) stdoutLogPath := filepath.Join(logDir, stdoutLogFile) @@ -1008,11 +1108,11 @@ func (g *BundleGenerator) addLogfile() error { stdoutLogPath = darwinStdoutLogPath } - if err := g.addSingleLogfile(stdErrLogPath, errorLogFile); err != nil { + if err := g.addSingleLogfile(openLogFile, stdErrLogPath, errorLogFile); err != nil { log.Warnf("Failed to add %s to zip: %v", errorLogFile, err) } - if err := g.addSingleLogfile(stdoutLogPath, stdoutLogFile); err != nil { + if err := g.addSingleLogfile(openLogFile, stdoutLogPath, stdoutLogFile); err != nil { log.Warnf("Failed to add %s to zip: %v", stdoutLogFile, err) } @@ -1029,18 +1129,18 @@ func (g *BundleGenerator) addUILog() error { return nil } - if err := g.addSingleLogfile(g.uiLogPath, uiLogFile); err != nil { + if err := g.addSingleLogfile(g.uiLogOpener, g.uiLogPath, configs.UILogFile); err != nil { return fmt.Errorf("add UI log file to zip: %w", err) } - g.addRotatedLogFiles(filepath.Dir(g.uiLogPath), uiLogPrefix) + g.addRotatedLogFiles(g.uiLogOpener, filepath.Dir(g.uiLogPath), uiLogPrefix) return nil } // addSingleLogfile adds a single log file to the archive -func (g *BundleGenerator) addSingleLogfile(logPath, targetName string) error { - logFile, err := os.Open(logPath) +func (g *BundleGenerator) addSingleLogfile(open LogOpener, logPath, targetName string) error { + logFile, err := open(logPath) if err != nil { return fmt.Errorf("open log file %s: %w", targetName, err) } @@ -1065,8 +1165,8 @@ func (g *BundleGenerator) addSingleLogfile(logPath, targetName string) error { } // addSingleLogFileGz adds a single gzipped log file to the archive -func (g *BundleGenerator) addSingleLogFileGz(logPath, targetName string) error { - f, err := os.Open(logPath) +func (g *BundleGenerator) addSingleLogFileGz(open LogOpener, logPath, targetName string) error { + f, err := open(logPath) if err != nil { return fmt.Errorf("open gz log file %s: %w", targetName, err) } @@ -1113,7 +1213,7 @@ func (g *BundleGenerator) addSingleLogFileGz(logPath, targetName string) error { // addRotatedLogFiles adds rotated log files to the bundle based on logFileCount. // prefix is the base log name without extension (e.g. "client", "gui-client"); // the glob matches both files rotated by us and by logrotate on linux. -func (g *BundleGenerator) addRotatedLogFiles(logDir, prefix string) { +func (g *BundleGenerator) addRotatedLogFiles(open LogOpener, logDir, prefix string) { if g.logFileCount == 0 { return } @@ -1153,9 +1253,9 @@ func (g *BundleGenerator) addRotatedLogFiles(logDir, prefix string) { for i := 0; i < maxFiles; i++ { name := filepath.Base(files[i]) if strings.HasSuffix(name, ".gz") { - err = g.addSingleLogFileGz(files[i], name) + err = g.addSingleLogFileGz(open, files[i], name) } else { - err = g.addSingleLogfile(files[i], name) + err = g.addSingleLogfile(open, files[i], name) } if err != nil { log.Warnf("failed to add rotated log %s: %v", name, err) @@ -1435,6 +1535,7 @@ func anonymizeRemotePeer(peer *mgmProto.RemotePeerConfig, anonymizer *anonymize. } peer.Fqdn = anonymizer.AnonymizeDomain(peer.Fqdn) + peer.WgPubKey = anonymizer.AnonymizeWGKey(peer.WgPubKey) anonymizeSSHConfig(peer.SshConfig) } diff --git a/client/internal/debug/debug_ios.go b/client/internal/debug/debug_ios.go index a07c23dbd..001d64241 100644 --- a/client/internal/debug/debug_ios.go +++ b/client/internal/debug/debug_ios.go @@ -27,7 +27,7 @@ func (g *BundleGenerator) addPlatformLog() error { } swiftLogPath := filepath.Join(filepath.Dir(g.logPath), swiftLogFile) - if err := g.addSingleLogfile(swiftLogPath, swiftLogFile); err != nil { + if err := g.addSingleLogfile(openLogFile, swiftLogPath, swiftLogFile); err != nil { // The Swift log is best-effort: the app may not have written it yet. log.Warnf("failed to add %s to debug bundle: %v", swiftLogFile, err) } diff --git a/client/internal/debug/debug_linux.go b/client/internal/debug/debug_linux.go index 40d864eda..a36c0c0e7 100644 --- a/client/internal/debug/debug_linux.go +++ b/client/internal/debug/debug_linux.go @@ -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", diff --git a/client/internal/debug/debug_logfiles_test.go b/client/internal/debug/debug_logfiles_test.go index 3749b3e9b..31420711f 100644 --- a/client/internal/debug/debug_logfiles_test.go +++ b/client/internal/debug/debug_logfiles_test.go @@ -97,7 +97,7 @@ func runAddRotatedLogFilesPrefix(t *testing.T, dir, prefix string, logFileCount archive: zip.NewWriter(&buf), logFileCount: logFileCount, } - g.addRotatedLogFiles(dir, prefix) + g.addRotatedLogFiles(openLogFile, dir, prefix) require.NoError(t, g.archive.Close()) zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) diff --git a/client/internal/debug/debug_nonunix.go b/client/internal/debug/debug_nonunix.go index 18d017050..adc9b9649 100644 --- a/client/internal/debug/debug_nonunix.go +++ b/client/internal/debug/debug_nonunix.go @@ -1,4 +1,4 @@ -//go:build !unix +//go:build !unix && !windows package debug diff --git a/client/internal/debug/debug_test.go b/client/internal/debug/debug_test.go index 8286f6852..17d520358 100644 --- a/client/internal/debug/debug_test.go +++ b/client/internal/debug/debug_test.go @@ -839,12 +839,13 @@ COMMIT` // the excluded set with a justification. func TestAddConfig_AllFieldsCovered(t *testing.T) { excluded := map[string]string{ - "PrivateKey": "sensitive: WireGuard private key", - "PreSharedKey": "sensitive: WireGuard pre-shared key", - "SSHKey": "sensitive: SSH private key", - "ClientCertKeyPair": "non-config: parsed cert pair, not serialized", - "Name": "non-config: profile name is not needed for debug purposes", - "policy": "non-config: in-memory MDM policy snapshot, surfaced via Config.Policy() / GetConfigResponse.MDMManagedFields", + "PrivateKey": "sensitive: WireGuard private key", + "PreSharedKey": "sensitive: WireGuard pre-shared key", + "SSHKey": "sensitive: SSH private key", + "ClientCertKeyPair": "non-config: parsed cert pair, not serialized", + "Name": "non-config: profile name is not needed for debug purposes", + "policy": "non-config: in-memory MDM policy snapshot, surfaced via Config.Policy() / GetConfigResponse.MDMManagedFields", + "DebugBundleUploadURL": "sensitive: MDM-provided upload URL may carry credentials or query tokens; kept out of the shared bundle", } mURL, _ := url.Parse("https://api.example.com:443") @@ -864,6 +865,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) { RosenpassEnabled: true, RosenpassPermissive: true, ServerSSHAllowed: &bTrue, + RemoteJobsAllowed: &bTrue, EnableSSHRoot: &bTrue, EnableSSHSFTP: &bTrue, EnableSSHLocalPortForwarding: &bTrue, @@ -886,7 +888,10 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) { ClientCertPath: "/tmp/cert", ClientCertKeyPath: "/tmp/key", LazyConnection: "on", + DebugBundleUploadURL: "https://upload.example.test/bundle?token=secret", MTU: 1280, + DisableIPv6: true, + SyncMessageVersion: func(v int) *int { return &v }(1), } for _, anonymize := range []bool{false, true} { @@ -901,6 +906,13 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) { g.addCommonConfigFields(&sb) rendered := sb.String() + renderAddConfigSpecific(g) + // DebugBundleUploadURL is an MDM-provided value that can carry + // credentials or signed query tokens. It is deliberately excluded + // above; assert it never reaches the rendered bundle — neither the + // field name nor the token — in either anonymize mode. + assert.NotContains(t, rendered, "DebugBundleUploadURL:", "MDM upload URL field must not be serialized into the debug bundle") + assert.NotContains(t, rendered, "token=secret", "MDM upload URL value must not leak into the debug bundle") + val := reflect.ValueOf(cfg).Elem() typ := val.Type() var missing []string diff --git a/client/internal/debug/debug_windows.go b/client/internal/debug/debug_windows.go new file mode 100644 index 000000000..e88940fd3 --- /dev/null +++ b/client/internal/debug/debug_windows.go @@ -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("", 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) + } +} diff --git a/client/internal/debug/debug_windows_test.go b/client/internal/debug/debug_windows_test.go new file mode 100644 index 000000000..47df3f6f9 --- /dev/null +++ b/client/internal/debug/debug_windows_test.go @@ -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") +} diff --git a/client/internal/debug/nrpt_windows.go b/client/internal/debug/nrpt_windows.go new file mode 100644 index 000000000..6b6e0e29a --- /dev/null +++ b/client/internal/debug/nrpt_windows.go @@ -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 +} diff --git a/client/internal/debug/uilog_test.go b/client/internal/debug/uilog_test.go new file mode 100644 index 000000000..103e98c6f --- /dev/null +++ b/client/internal/debug/uilog_test.go @@ -0,0 +1,64 @@ +package debug + +import ( + "archive/zip" + "bytes" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/configs" +) + +// bundleEntries generates a bundle with the given generator and returns the +// set of entry names in the resulting archive. +func bundleEntries(t *testing.T, g *BundleGenerator) map[string]struct{} { + t.Helper() + + path, err := g.Generate() + require.NoError(t, err) + t.Cleanup(func() { _ = os.Remove(path) }) + + data, err := os.ReadFile(path) + require.NoError(t, err) + + zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + require.NoError(t, err) + + names := make(map[string]struct{}, len(zr.File)) + for _, f := range zr.File { + names[f.Name] = struct{}{} + } + return names +} + +func TestBundleIncludesUILogWhenOpenerAllows(t *testing.T) { + path := filepath.Join(t.TempDir(), configs.UILogFile) + require.NoError(t, os.WriteFile(path, []byte("gui log"), 0600)) + + g := NewBundleGenerator(GeneratorDependencies{ + UILogPath: path, + UILogOpener: openLogFile, + }, BundleConfig{}) + + require.Contains(t, bundleEntries(t, g), configs.UILogFile) +} + +// A UILogOpener that refuses (as the ownership check does for a foreign file) +// keeps the UI log out of the bundle without failing bundle generation. +func TestBundleExcludesUILogWhenOpenerRefuses(t *testing.T) { + path := filepath.Join(t.TempDir(), configs.UILogFile) + require.NoError(t, os.WriteFile(path, []byte("secret"), 0600)) + + g := NewBundleGenerator(GeneratorDependencies{ + UILogPath: path, + UILogOpener: func(string) (*os.File, error) { + return nil, fmt.Errorf("not owned by the caller") + }, + }, BundleConfig{}) + + require.NotContains(t, bundleEntries(t, g), configs.UILogFile) +} diff --git a/client/internal/debug/upload.go b/client/internal/debug/upload.go index cdf52409d..88fde6d6f 100644 --- a/client/internal/debug/upload.go +++ b/client/internal/debug/upload.go @@ -3,10 +3,12 @@ package debug import ( "context" "crypto/sha256" + "crypto/tls" "encoding/json" "fmt" "io" "net/http" + neturl "net/url" "os" "github.com/netbirdio/netbird/upload-server/types" @@ -14,20 +16,80 @@ import ( const maxBundleUploadSize = 50 * 1024 * 1024 -func UploadDebugBundle(ctx context.Context, url, managementURL, filePath string) (key string, err error) { - response, err := getUploadURL(ctx, url, managementURL) +// requireHTTPS refuses any URL the daemon would fetch or upload to that is not +// https. The daemon runs as root and the bundle carries its logs and state, so a +// plaintext hop is a place to intercept the bundle or the presigned redirect. +// The server-side gate already enforces this for the desktop path; this also +// covers the mobile and job-runner callers that reach this package directly. +// Skipped when the caller opted into an insecure upload (self-hosted server). +func requireHTTPS(what, rawURL string) error { + parsed, err := neturl.Parse(rawURL) + if err != nil { + return fmt.Errorf("parse %s: %w", what, err) + } + if parsed.Scheme != "https" { + return fmt.Errorf("%s must use https, got scheme %q", what, parsed.Scheme) + } + return nil +} + +// uploadClient returns the HTTP client for the upload requests. The default +// client verifies TLS and refuses a redirect that would downgrade to a non-https +// hop, so a bundle can never leave over http after an https start. The insecure +// variant accepts http and untrusted certificates, and is only reachable for a +// privileged caller that passed --upload-bundle-insecure (see +// requirePrivilegeForUploadURL). +func uploadClient(insecure bool) *http.Client { + if !insecure { + return &http.Client{CheckRedirect: rejectInsecureRedirect} + } + return &http.Client{ + Transport: &http.Transport{ + //nolint:gosec // opt-in, privileged, self-hosted upload servers + TLSClientConfig: &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12}, + }, + } +} + +// rejectInsecureRedirect refuses a redirect to a non-https target and keeps the +// standard library's 10-hop limit that a custom CheckRedirect would otherwise +// disable. +func rejectInsecureRedirect(req *http.Request, via []*http.Request) error { + if req.URL.Scheme != "https" { + return fmt.Errorf("refusing redirect to non-https URL %s", req.URL.Redacted()) + } + if len(via) >= 10 { + return fmt.Errorf("stopped after 10 redirects") + } + return nil +} + +func UploadDebugBundle(ctx context.Context, url, managementURL, filePath string, insecure bool) (key string, err error) { + if !insecure { + if err := requireHTTPS("upload service URL", url); err != nil { + return "", err + } + } + + response, err := getUploadURL(ctx, url, managementURL, insecure) if err != nil { return "", err } - err = upload(ctx, filePath, response) + if !insecure { + if err := requireHTTPS("upload URL from service", response.URL); err != nil { + return "", err + } + } + + err = upload(ctx, filePath, response, insecure) if err != nil { return "", err } return response.Key, nil } -func upload(ctx context.Context, filePath string, response *types.GetURLResponse) error { +func upload(ctx context.Context, filePath string, response *types.GetURLResponse, insecure bool) error { fileData, err := os.Open(filePath) if err != nil { return fmt.Errorf("open file: %w", err) @@ -52,7 +114,7 @@ func upload(ctx context.Context, filePath string, response *types.GetURLResponse req.ContentLength = stat.Size() req.Header.Set("Content-Type", "application/octet-stream") - putResp, err := http.DefaultClient.Do(req) + putResp, err := uploadClient(insecure).Do(req) if err != nil { return fmt.Errorf("upload failed: %v", err) } @@ -65,16 +127,23 @@ func upload(ctx context.Context, filePath string, response *types.GetURLResponse return nil } -func getUploadURL(ctx context.Context, url string, managementURL string) (*types.GetURLResponse, error) { - id := getURLHash(managementURL) - getReq, err := http.NewRequestWithContext(ctx, "GET", url+"?id="+id, nil) +func getUploadURL(ctx context.Context, serviceURL string, managementURL string, insecure bool) (*types.GetURLResponse, error) { + parsed, err := neturl.Parse(serviceURL) + if err != nil { + return nil, fmt.Errorf("parse upload service URL: %w", err) + } + q := parsed.Query() + q.Set("id", getURLHash(managementURL)) + parsed.RawQuery = q.Encode() + + getReq, err := http.NewRequestWithContext(ctx, "GET", parsed.String(), nil) if err != nil { return nil, fmt.Errorf("create GET request: %w", err) } getReq.Header.Set(types.ClientHeader, types.ClientHeaderValue) - resp, err := http.DefaultClient.Do(getReq) + resp, err := uploadClient(insecure).Do(getReq) if err != nil { return nil, fmt.Errorf("get presigned URL: %w", err) } diff --git a/client/internal/debug/upload_test.go b/client/internal/debug/upload_test.go index f224b8d3f..f3927cb81 100644 --- a/client/internal/debug/upload_test.go +++ b/client/internal/debug/upload_test.go @@ -5,6 +5,7 @@ import ( "errors" "net" "net/http" + "net/http/httptest" "os" "path/filepath" "testing" @@ -43,7 +44,7 @@ func TestUpload(t *testing.T) { fileContent := []byte("test file content") err := os.WriteFile(file, fileContent, 0640) require.NoError(t, err) - key, err := UploadDebugBundle(context.Background(), testURL+types.GetURLPath, testURL, file) + key, err := UploadDebugBundle(context.Background(), testURL+types.GetURLPath, testURL, file, true) require.NoError(t, err) id := getURLHash(testURL) require.Contains(t, key, id+"/") @@ -79,3 +80,47 @@ func waitForServer(t *testing.T, addr string) { } t.Fatalf("server did not start listening on %s in time", addr) } + +func TestRequireHTTPS(t *testing.T) { + require.NoError(t, requireHTTPS("upload URL", "https://upload.example/path")) + require.Error(t, requireHTTPS("upload URL", "http://upload.example/path")) + require.Error(t, requireHTTPS("upload URL", "ftp://upload.example/path")) + require.Error(t, requireHTTPS("upload URL", "://malformed")) +} + +func TestRejectInsecureRedirect(t *testing.T) { + httpsReq, err := http.NewRequest(http.MethodGet, "https://a.example/", nil) + require.NoError(t, err) + require.NoError(t, rejectInsecureRedirect(httpsReq, nil), "https redirect target must be allowed") + + httpReq, err := http.NewRequest(http.MethodGet, "http://a.example/", nil) + require.NoError(t, err) + require.Error(t, rejectInsecureRedirect(httpReq, nil), "http redirect target must be refused") + + require.Error(t, rejectInsecureRedirect(httpsReq, make([]*http.Request, 10)), "the 10-redirect limit must be enforced") +} + +// The secure client refuses to follow an https response that redirects to http, +// so a bundle can't be downgraded onto plaintext mid-flight. +func TestUploadClientRefusesHTTPSToHTTPRedirect(t *testing.T) { + plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(plain.Close) + + secure := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, plain.URL, http.StatusFound) + })) + t.Cleanup(secure.Close) + + client := uploadClient(false) + // Trust the test server's cert without disabling verification globally. + client.Transport = secure.Client().Transport + + resp, err := client.Get(secure.URL) + if resp != nil { + _ = resp.Body.Close() + } + require.Error(t, err, "redirect from https to http must be refused") + require.Contains(t, err.Error(), "non-https") +} diff --git a/client/internal/debug/wgshow.go b/client/internal/debug/wgshow.go index 1e8a8a6cc..ee24902e6 100644 --- a/client/internal/debug/wgshow.go +++ b/client/internal/debug/wgshow.go @@ -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, ", "))) } diff --git a/client/internal/dns/host_darwin.go b/client/internal/dns/host_darwin.go index 0f4eb6bf8..81029752e 100644 --- a/client/internal/dns/host_darwin.go +++ b/client/internal/dns/host_darwin.go @@ -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//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 : {": 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 { diff --git a/client/internal/dns/host_darwin_test.go b/client/internal/dns/host_darwin_test.go index 94d020c39..bee691c71 100644 --- a/client/internal/dns/host_darwin_test.go +++ b/client/internal/dns/host_darwin_test.go @@ -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: ` { + DomainName : example.com + SearchDomains : { + 0 : example.com + 1 : corp.example.com + } + ServerAddresses : { + 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: ` { + SearchDomains : { + 0 : + (null) + + 1 : corp.example.com + } + ServerAddresses : { + 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: ` { + DomainName : + ServerAddresses : { + 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: ` { + ServerAddresses : { + 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: ` { + ServerAddresses : { + 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: ` { + ServerAddresses : { + 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{}), diff --git a/client/internal/dns/host_windows.go b/client/internal/dns/host_windows.go index 4f6ece532..6462d0c37 100644 --- a/client/internal/dns/host_windows.go +++ b/client/internal/dns/host_windows.go @@ -6,8 +6,10 @@ import ( "fmt" "io" "net/netip" + "os" "os/exec" "slices" + "strconv" "strings" "syscall" "time" @@ -31,10 +33,52 @@ 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: + // the match rules, the catch-all, and the .local exemption. Cleanup + // enumerates by this prefix, so a new kind of rule is removed by existing + // code as long as its key starts here. + NRPTKeyPrefix = "NetBird-" + + // nrptMatchKeyName names the match-domain rules. Older versions used + // different layouts under the same name: a single unsuffixed key, then one + // key per domain, now one key per batch of domains. + nrptMatchKeyName = NRPTKeyPrefix + "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 + `\` + nrptMatchKeyName + gpoDnsPolicyConfigMatchPath = GPODNSPolicyConfigRoot + `\` + nrptMatchKeyName + + dnsPolicyConfigExemptLocalPath = DNSPolicyConfigRoot + `\` + NRPTKeyPrefix + `ExemptLocal` + gpoDnsPolicyConfigExemptLocalPath = GPODNSPolicyConfigRoot + `\` + NRPTKeyPrefix + `ExemptLocal` + + nrptCatchAllNamespace = "." + // nrptLocalNamespace is reserved for multicast DNS by RFC 6762: a unicast + // resolver must not answer for it. The catch-all rule would hand it to us + // anyway, so it gets an exemption rule of its own. + nrptLocalNamespace = ".local" + + // envLegacyDNSResolution restores the pre-catch-all behaviour: the adapter's + // NameServer alone, leaving the OS free to query other adapters' resolvers in + // parallel. An escape hatch for setups that depend on a resolver of theirs + // still being reachable while connected, at the cost of the leak and of the + // race the catch-all rule exists to close. + envLegacyDNSResolution = "NB_USE_LEGACY_DNS_RESOLUTION" dnsPolicyConfigVersionKey = "Version" dnsPolicyConfigVersionValue = 2 @@ -45,8 +89,6 @@ const ( nrptMaxDomainsPerRule = 50 - interfaceConfigPath = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces` - interfaceConfigPathV6 = `SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters\Interfaces` interfaceConfigNameServerKey = "NameServer" interfaceConfigDhcpNameSrvKey = "DhcpNameServer" interfaceConfigSearchListKey = "SearchList" @@ -73,7 +115,6 @@ type registryConfigurator struct { guid string routingAll bool gpo bool - nrptEntryCount int origNameservers []netip.Addr } @@ -83,19 +124,9 @@ func newHostManager(wgInterface WGIface) (*registryConfigurator, error) { return nil, err } - var useGPO bool - k, err := registry.OpenKey(registry.LOCAL_MACHINE, gpoDnsPolicyRoot, registry.QUERY_VALUE) - if err != nil { - log.Debugf("failed to open GPO DNS policy root: %v", err) - } else { - closer(k) - useGPO = true - log.Infof("detected GPO DNS policy configuration, using policy store") - } - configurator := ®istryConfigurator{ guid: guid, - gpo: useGPO, + gpo: useGPOPolicyStore(), } origNameservers, err := configurator.captureOriginalNameservers() @@ -123,7 +154,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)) @@ -276,6 +307,13 @@ func (r *registryConfigurator) disableWINSForInterface() error { } func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager *statemanager.Manager) error { + // Clear every rule the previous apply installed before installing any new + // one, including a leftover catch-all: removal is unconditional so a rule + // from an earlier run cannot survive into a config that no longer wants it. + if err := r.removeDNSMatchPolicies(); err != nil { + log.Errorf("cleanup old dns match policies: %s", err) + } + if config.RouteAll { if err := r.addDNSSetupForAll(config.ServerIP); err != nil { return fmt.Errorf("add dns setup: %w", err) @@ -301,19 +339,28 @@ func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager matchDomains = append(matchDomains, "."+strings.TrimSuffix(dConf.Domain, ".")) } - if err := r.removeDNSMatchPolicies(); err != nil { - log.Errorf("cleanup old dns match policies: %s", err) + // The root namespace is a match domain like any other: it just happens to + // match every name. Without it the adapter's NameServer only adds one more + // resolver to the set Windows queries in parallel, keeping whichever answer + // comes back first — which leaks every query to the local network and lets a + // resolver other than ours answer for a name we are authoritative for. + if config.RouteAll { + if parseBoolEnv(envLegacyDNSResolution) { + log.Infof("%s is set, leaving DNS resolution shared with the other adapters' resolvers instead of forcing it through %s", envLegacyDNSResolution, config.ServerIP) + } else { + matchDomains = append(matchDomains, nrptCatchAllNamespace) + log.Infof("routing every namespace through %s: DNS resolution is now exclusive to NetBird", config.ServerIP) + + if err := r.addDNSExemptLocalPolicy(); err != nil { + return fmt.Errorf("add dns exempt policy: %w", err) + } + } } if len(matchDomains) != 0 { - count, err := r.addDNSMatchPolicy(matchDomains, config.ServerIP) - // Update count even on error to ensure cleanup covers partially created rules - r.nrptEntryCount = count - if err != nil { + if err := r.addDNSMatchPolicy(matchDomains, config.ServerIP); err != nil { return fmt.Errorf("add dns match policy: %w", err) } - } else { - r.nrptEntryCount = 0 } r.updateState(stateManager) @@ -329,9 +376,8 @@ func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager func (r *registryConfigurator) updateState(stateManager *statemanager.Manager) { if err := stateManager.UpdateState(&ShutdownState{ - Guid: r.guid, - GPO: r.gpo, - NRPTEntryCount: r.nrptEntryCount, + Guid: r.guid, + GPO: r.gpo, }); err != nil { log.Errorf("failed to update shutdown state: %s", err) } @@ -346,7 +392,7 @@ func (r *registryConfigurator) addDNSSetupForAll(ip netip.Addr) error { return nil } -func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr) (int, error) { +func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr) error { // if the gpo key is present, we need to put our DNS settings there, otherwise our config might be ignored // see https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-gpnrpt/8cc31cb9-20cb-4140-9e85-3e08703b4745 @@ -363,19 +409,17 @@ func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr gpoPath := fmt.Sprintf("%s-%d", gpoDnsPolicyConfigMatchPath, ruleIndex) if err := r.configureDNSPolicy(localPath, batchDomains, ip); err != nil { - return ruleIndex, fmt.Errorf("configure DNS Local policy for rule %d: %w", ruleIndex, err) + return fmt.Errorf("configure DNS Local policy for rule %d: %w", ruleIndex, err) } - // Increment immediately so the caller's cleanup path knows about this rule - ruleIndex++ - if r.gpo { if err := r.configureDNSPolicy(gpoPath, batchDomains, ip); err != nil { - return ruleIndex, fmt.Errorf("configure gpo DNS policy for rule %d: %w", ruleIndex-1, err) + return fmt.Errorf("configure gpo DNS policy for rule %d: %w", ruleIndex, err) } } - log.Debugf("added NRPT rule %d with %d domains", ruleIndex-1, len(batchDomains)) + log.Debugf("added NRPT rule %d with %d domains", ruleIndex, len(batchDomains)) + ruleIndex++ } if r.gpo { @@ -385,9 +429,45 @@ func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr } log.Infof("added %d NRPT rules for %d domains", ruleIndex, len(domains)) - return ruleIndex, nil + return nil } +// addDNSExemptLocalPolicy carves .local back out of the catch-all. RFC 6762 +// reserves it for multicast DNS, so forwarding those names to a unicast +// upstream answers NXDOMAIN for hosts that do exist - printers, NAS boxes, and +// anything else announcing itself on the link - and the answer is authoritative +// enough that Windows stops looking. A rule naming the namespace with no +// servers hands it back to the DNS client untouched. A more specific rule still +// wins, so a match domain under .local keeps going through us. +func (r *registryConfigurator) addDNSExemptLocalPolicy() error { + var noServers netip.Addr + + if err := r.configureDNSPolicy(dnsPolicyConfigExemptLocalPath, []string{nrptLocalNamespace}, noServers); err != nil { + return fmt.Errorf("configure exempt policy for %s: %w", nrptLocalNamespace, err) + } + + if r.gpo { + if err := r.configureDNSPolicy(gpoDnsPolicyConfigExemptLocalPath, []string{nrptLocalNamespace}, noServers); err != nil { + return fmt.Errorf("configure gpo exempt policy for %s: %w", nrptLocalNamespace, err) + } + if err := refreshGroupPolicy(); err != nil { + log.Warnf("failed to refresh group policy: %v", err) + } + } + + log.Infof("added NRPT exemption for %s, leaving it to the OS resolver", nrptLocalNamespace) + return nil +} + +// configureDNSPolicy writes one NRPT rule. An invalid ip writes an exemption +// rule: the namespace with an empty server list, which tells the DNS client to +// resolve those names the way it would without any rule at all. +// +// The empty string is the whole difference, and it has to be written: dropping +// the value and clearing ConfigOptions instead produces a rule Windows treats +// as a no-op, keeps out of Get-DnsClientNrptPolicy -Effective, and ignores in +// favour of the catch-all. 0x8 says the server list is the meaningful part of +// the rule, and an empty list then means "no server, resolve normally". func (r *registryConfigurator) configureDNSPolicy(policyPath string, domains []string, ip netip.Addr) error { if err := removeRegistryKeyFromDNSPolicyConfig(policyPath); err != nil { return fmt.Errorf("remove existing dns policy: %w", err) @@ -407,7 +487,11 @@ func (r *registryConfigurator) configureDNSPolicy(policyPath string, domains []s return fmt.Errorf("set %s: %w", dnsPolicyConfigNameKey, err) } - if err := regKey.SetStringValue(dnsPolicyConfigGenericDNSServersKey, ip.String()); err != nil { + var servers string + if ip.IsValid() { + servers = ip.String() + } + if err := regKey.SetStringValue(dnsPolicyConfigGenericDNSServersKey, servers); err != nil { return fmt.Errorf("set %s: %w", dnsPolicyConfigGenericDNSServersKey, err) } @@ -450,7 +534,7 @@ func (r *registryConfigurator) flushDNSCache() { ret, _, err := dnsFlushResolverCacheFn.Call() if ret == 0 { - if err != nil && !errors.Is(err, syscall.Errno(0)) { + if !errors.Is(err, syscall.Errno(0)) { log.Errorf("DnsFlushResolverCache failed: %v", err) return } @@ -482,21 +566,29 @@ func (r *registryConfigurator) setInterfaceRegistryKeyStringValue(key, value str return nil } +// deleteInterfaceRegistryKeyProperty removes a value from the interface key. +// A value that is already gone, or an interface key that is, is not an error: +// the caller asked for the value not to be there, and a cleanup that runs twice +// has to reach its later steps on the second run as well. func (r *registryConfigurator) deleteInterfaceRegistryKeyProperty(propertyKey string) error { regKey, err := r.getInterfaceRegistryKey() - if err != nil { + switch { + case errors.Is(err, registry.ErrNotExist), errors.Is(err, syscall.ERROR_PATH_NOT_FOUND): + log.Debugf("interface key of %s does not exist, nothing to delete %s from", r.guid, propertyKey) + return nil + case err != nil: return fmt.Errorf("get interface registry key: %w", err) } defer closer(regKey) - if err := regKey.DeleteValue(propertyKey); err != nil { + if err := regKey.DeleteValue(propertyKey); err != nil && !errors.Is(err, registry.ErrNotExist) { return fmt.Errorf("delete registry key %s: %w", propertyKey, err) } return nil } 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) @@ -505,8 +597,11 @@ func (r *registryConfigurator) getInterfaceRegistryKey() (registry.Key, error) { } func (r *registryConfigurator) restoreHostDNS() error { + // Propagated, unlike in applyDNSConfig: there we are about to write fresh + // rules over whatever survived, here we are leaving, and a rule left behind + // keeps sending every query to an address that is about to disappear. if err := r.removeDNSMatchPolicies(); err != nil { - log.Errorf("remove dns match policies: %s", err) + return fmt.Errorf("remove dns match policies: %w", err) } if err := r.deleteInterfaceRegistryKeyProperty(interfaceConfigSearchListKey); err != nil { @@ -515,31 +610,36 @@ func (r *registryConfigurator) restoreHostDNS() error { go r.flushDNSCache() - return nil + // Last, and only on the way out, once no rule of ours is left: during a + // session the store is where the rules of this run live, and emptying it + // mid-session would have the next rule recreate it anyway. Propagated so a + // failure keeps the shutdown state for the next run to retry, rather than + // leaving the store to hold up every rule change from here on. + return removeEmptyGPOPolicyStore() } +// removeDNSMatchPolicies deletes every NRPT rule this client may have created, +// from the local and the GPO policy store. The rules are found by enumerating +// the registry, the only authoritative record of what was written. Cleanup must +// not depend on a rule count: the in-memory one is scoped to a single +// registryConfigurator and the persisted one is deleted on every clean +// disconnect, and a rule left behind keeps resolving names over an interface +// that is gone, until reboot discards the volatile key. func (r *registryConfigurator) removeDNSMatchPolicies() error { var merr *multierror.Error - // Try to remove the base entries (for backward compatibility) - if err := removeRegistryKeyFromDNSPolicyConfig(dnsPolicyConfigMatchPath); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove local base entry: %w", err)) - } - - if err := removeRegistryKeyFromDNSPolicyConfig(gpoDnsPolicyConfigMatchPath); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove GPO base entry: %w", err)) - } - - for i := 0; i < r.nrptEntryCount; i++ { - localPath := fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i) - gpoPath := fmt.Sprintf("%s-%d", gpoDnsPolicyConfigMatchPath, i) - - if err := removeRegistryKeyFromDNSPolicyConfig(localPath); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove local entry %d: %w", i, err)) + for _, root := range []string{DNSPolicyConfigRoot, GPODNSPolicyConfigRoot} { + names, err := listNRPTRuleKeys(root) + if err != nil { + merr = multierror.Append(merr, fmt.Errorf("list rule keys under %s: %w", root, err)) + continue } - if err := removeRegistryKeyFromDNSPolicyConfig(gpoPath); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove GPO entry %d: %w", i, err)) + for _, name := range names { + path := root + `\` + name + if err := removeRegistryKeyFromDNSPolicyConfig(path); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove entry %s: %w", path, err)) + } } } @@ -554,11 +654,119 @@ func (r *registryConfigurator) restoreUncleanShutdownDNS() error { return r.restoreHostDNS() } +// useGPOPolicyStore reports whether NRPT rules have to go into the group policy +// store, and clears an empty one out of the way first. +// +// The order is the point. A store left empty by an earlier run would otherwise +// decide this run too, sending its rules somewhere the resolver only reads when +// the policy engine next applies DNS client policy. Removing it before the +// choice is made leaves the local store authoritative for the whole session, +// including the first one after an upgrade. +func useGPOPolicyStore() bool { + if err := removeEmptyGPOPolicyStore(); err != nil { + // Nothing to retry against here: the worst case is the run going + // through the group policy store, which is where it would have gone + // before this check existed. + log.Warnf("%v", err) + } + + 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) + return false + } + closer(k) + + log.Infof("detected GPO DNS policy configuration, using policy store") + return true +} + +// removeEmptyGPOPolicyStore deletes the group policy DnsPolicyConfig key once +// nothing is left in it. The key survives the deletion of the last rule it +// held, and the client treats its presence as "group policy configures the +// NRPT", so an empty one left behind keeps every later run writing rules there. +// Rules in that store reach the resolver only when the policy engine next +// applies DNS client policy, and a rule this client writes belongs to no GPO, +// so nothing schedules that application: both adding and removing a rule are +// held up by a minute or more, and for a removal that is a catch-all rule +// resolving every name over an interface that no longer exists. With the store +// absent the local one is authoritative and a change applies at once. +// +// A store that still holds rules, values or subkeys of somebody else's is left +// alone. +func removeEmptyGPOPolicyStore() error { + k, err := registry.OpenKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot, registry.QUERY_VALUE) + switch { + case errors.Is(err, registry.ErrNotExist), errors.Is(err, syscall.ERROR_PATH_NOT_FOUND): + return nil + case err != nil: + return fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", GPODNSPolicyConfigRoot, err) + } + + info, err := k.Stat() + closer(k) + if err != nil { + return fmt.Errorf("stat HKEY_LOCAL_MACHINE\\%s: %w", GPODNSPolicyConfigRoot, err) + } + + if info.SubKeyCount != 0 || info.ValueCount != 0 { + return nil + } + + if err := registry.DeleteKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot); err != nil { + return fmt.Errorf("delete empty HKEY_LOCAL_MACHINE\\%s: %w", GPODNSPolicyConfigRoot, err) + } + + log.Infof("removed the empty GPO DNS policy store, leaving the local one authoritative") + return nil +} + +// listNRPTRuleKeys returns the names of our NRPT rule keys under a policy store +// root. An absent root holds nothing to clean up, which is the normal state of +// the GPO store on a machine without DNS Client policy. +func listNRPTRuleKeys(root string) ([]string, error) { + k, err := registry.OpenKey(registry.LOCAL_MACHINE, root, registry.ENUMERATE_SUB_KEYS) + switch { + case errors.Is(err, registry.ErrNotExist), errors.Is(err, syscall.ERROR_PATH_NOT_FOUND): + // the GPO store is absent on a machine without DNS client policy + log.Debugf("HKEY_LOCAL_MACHINE\\%s does not exist", root) + return nil, nil + case err != nil: + // any other failure has to reach the caller: reporting no rules would + // report a successful cleanup while leaving the rules in place + return nil, fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", root, err) + } + defer closer(k) + + names, err := k.ReadSubKeyNames(-1) + if err != nil { + return nil, fmt.Errorf("read subkey names: %w", err) + } + + var ruleKeys []string + for _, name := range names { + // registry key names are case insensitive + if strings.HasPrefix(strings.ToLower(name), strings.ToLower(NRPTKeyPrefix)) { + ruleKeys = append(ruleKeys, name) + } + } + + return ruleKeys, nil +} + func removeRegistryKeyFromDNSPolicyConfig(regKeyPath string) error { k, err := registry.OpenKey(registry.LOCAL_MACHINE, regKeyPath, registry.QUERY_VALUE) - if err != nil { - log.Debugf("failed to open HKEY_LOCAL_MACHINE\\%s: %v", regKeyPath, err) + switch { + case errors.Is(err, registry.ErrNotExist), errors.Is(err, syscall.ERROR_PATH_NOT_FOUND): + // nothing to remove, which is the normal case for a rule this config + // never installed + log.Debugf("HKEY_LOCAL_MACHINE\\%s does not exist", regKeyPath) return nil + case err != nil: + // anything else has to reach the caller: reporting success here would + // leave the rule in force while claiming it was removed, which is how a + // stale rule outlives the interface it points at + return fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", regKeyPath, err) } closer(k) @@ -585,7 +793,7 @@ func refreshGroupPolicy() error { ) if ret == 0 { - if err != nil && !errors.Is(err, syscall.Errno(0)) { + if !errors.Is(err, syscall.Errno(0)) { return fmt.Errorf("RefreshPolicyEx failed: %w", err) } return fmt.Errorf("RefreshPolicyEx failed") @@ -594,6 +802,20 @@ func refreshGroupPolicy() error { return nil } +func parseBoolEnv(key string) bool { + val := os.Getenv(key) + if val == "" { + return false + } + + parsed, err := strconv.ParseBool(val) + if err != nil { + log.Warnf("failed to parse %s=%q: %v", key, val, err) + return false + } + return parsed +} + func closer(closer io.Closer) { if err := closer.Close(); err != nil { log.Errorf("failed to close: %s", err) diff --git a/client/internal/dns/host_windows_test.go b/client/internal/dns/host_windows_test.go index 3cd2b1bd5..353f6adbc 100644 --- a/client/internal/dns/host_windows_test.go +++ b/client/internal/dns/host_windows_test.go @@ -8,6 +8,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/sys/windows/registry" + + "github.com/netbirdio/netbird/client/internal/winregistry" ) // TestNRPTEntriesCleanupOnConfigChange tests that old NRPT entries are properly cleaned up @@ -25,7 +27,7 @@ func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) { // Create a test interface registry key so updateSearchDomains doesn't fail testGUID := "{12345678-1234-1234-1234-123456789ABC}" - interfacePath := `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\` + testGUID + interfacePath := InterfaceConfigPath + `\` + testGUID testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE) require.NoError(t, err, "Should create test interface registry key") testKey.Close() @@ -56,7 +58,7 @@ func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) { require.NoError(t, err) // Verify 3 NRPT rules exist - assert.Equal(t, 3, cfg.nrptEntryCount, "Should create 3 NRPT rules for 125 domains") + assert.Equal(t, 3, countNRPTRuleKeys(t), "Should create 3 NRPT rules for 125 domains") for i := 0; i < 3; i++ { exists, err := registryKeyExists(fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i)) require.NoError(t, err) @@ -81,7 +83,7 @@ func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) { require.NoError(t, err) // Verify first 2 NRPT rules exist - assert.Equal(t, 2, cfg.nrptEntryCount, "Should create 2 NRPT rules for 75 domains") + assert.Equal(t, 2, countNRPTRuleKeys(t), "Should create 2 NRPT rules for 75 domains") for i := 0; i < 2; i++ { exists, err := registryKeyExists(fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i)) require.NoError(t, err) @@ -94,6 +96,145 @@ func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) { assert.False(t, exists, "NRPT rule 2 should NOT exist after reducing to 75 domains") } +// TestNRPTCatchAllRule verifies that RouteAll adds the root namespace to the +// match rule instead of a rule of its own, that .local is carved back out with +// an empty server list, and that both go away when RouteAll is cleared or the +// host DNS is restored. +func TestNRPTCatchAllRule(t *testing.T) { + if testing.Short() { + t.Skip("skipping registry integration test in short mode") + } + + defer cleanupRegistryKeys(t) + cleanupRegistryKeys(t) + + testIP := netip.MustParseAddr("100.64.0.1") + testGUID := "{12345678-1234-1234-1234-123456789ABC}" + interfacePath := InterfaceConfigPath + `\` + testGUID + testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE) + require.NoError(t, err, "Should create test interface registry key") + require.NoError(t, testKey.Close(), "close test interface registry key") + defer func() { + assert.NoError(t, registry.DeleteKey(registry.LOCAL_MACHINE, interfacePath), "delete test interface registry key") + }() + + cfg := ®istryConfigurator{guid: testGUID} + + matchOnly := HostDNSConfig{ + ServerIP: testIP, + Domains: []DomainConfig{{Domain: "example.com", MatchOnly: true}}, + } + primary := HostDNSConfig{ + ServerIP: testIP, + RouteAll: true, + Domains: []DomainConfig{{Domain: "example.com", MatchOnly: true}}, + } + firstRule := fmt.Sprintf("%s-0", dnsPolicyConfigMatchPath) + + // The root namespace is not a rule of its own: it rides in the match rule, + // which is the point of it not being a special case. + require.NoError(t, cfg.applyDNSConfig(matchOnly, nil)) + names := ruleNamespaces(t, firstRule) + assert.Contains(t, names, ".example.com") + assert.NotContains(t, names, nrptCatchAllNamespace, "a match-only config must not claim every namespace") + + require.NoError(t, cfg.applyDNSConfig(primary, nil)) + names = ruleNamespaces(t, firstRule) + assert.Contains(t, names, ".example.com") + assert.Contains(t, names, nrptCatchAllNamespace, "RouteAll should add the root namespace to the match rule") + + k, err := registry.OpenKey(registry.LOCAL_MACHINE, firstRule, registry.QUERY_VALUE) + require.NoError(t, err) + servers, _, err := k.GetStringValue(dnsPolicyConfigGenericDNSServersKey) + require.NoError(t, err) + assert.Equal(t, testIP.String(), servers, "every namespace in the rule resolves through our resolver") + require.NoError(t, k.Close(), "close match rule key") + + // .local is carved back out: RFC 6762 reserves it for mDNS, so it needs a + // rule of its own — it is the one rule with a different server list. + ek, err := registry.OpenKey(registry.LOCAL_MACHINE, dnsPolicyConfigExemptLocalPath, registry.QUERY_VALUE) + require.NoError(t, err, "exemption rule should exist once the root namespace is claimed") + + exemptNames, _, err := ek.GetStringsValue(dnsPolicyConfigNameKey) + require.NoError(t, err) + assert.Equal(t, []string{nrptLocalNamespace}, exemptNames, "the exemption should name only the mDNS namespace") + + exemptServers, _, err := ek.GetStringValue(dnsPolicyConfigGenericDNSServersKey) + require.NoError(t, err, "the value has to be present, empty: without it Windows drops the rule") + assert.Empty(t, exemptServers, "an exemption rule lists no servers") + + exemptOpts, _, err := ek.GetIntegerValue(dnsPolicyConfigConfigOptionsKey) + require.NoError(t, err) + assert.EqualValues(t, dnsPolicyConfigConfigOptionsValue, exemptOpts, "same options as a normal rule; the empty server list is what makes it an exemption") + require.NoError(t, ek.Close(), "close exemption rule key") + + require.NoError(t, cfg.applyDNSConfig(matchOnly, nil)) + names = ruleNamespaces(t, firstRule) + assert.NotContains(t, names, nrptCatchAllNamespace, "clearing RouteAll should drop the root namespace") + + exists, err := registryKeyExists(dnsPolicyConfigExemptLocalPath) + require.NoError(t, err) + assert.False(t, exists, "exemption rule should go with the namespace it carves out of") + + require.NoError(t, cfg.applyDNSConfig(primary, nil)) + require.NoError(t, cfg.restoreHostDNS()) + exists, err = registryKeyExists(firstRule) + require.NoError(t, err) + assert.False(t, exists, "restore should leave no rule behind") +} + +// ruleNamespaces returns the namespaces an NRPT rule key claims. +func ruleNamespaces(t *testing.T, path string) []string { + t.Helper() + k, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.QUERY_VALUE) + require.NoError(t, err, "rule key %s should exist", path) + defer k.Close() + + names, _, err := k.GetStringsValue(dnsPolicyConfigNameKey) + require.NoError(t, err) + return names +} + +// TestNRPTCatchAllRuleLegacyEnv verifies that NB_USE_LEGACY_DNS_RESOLUTION +// leaves the root namespace unclaimed, so no rule is written for a RouteAll +// config that carries no match domains. +func TestNRPTCatchAllRuleLegacyEnv(t *testing.T) { + if testing.Short() { + t.Skip("skipping registry integration test in short mode") + } + + defer cleanupRegistryKeys(t) + cleanupRegistryKeys(t) + + t.Setenv(envLegacyDNSResolution, "true") + + testGUID := "{12345678-1234-1234-1234-123456789ABC}" + interfacePath := InterfaceConfigPath + `\` + testGUID + testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE) + require.NoError(t, err, "Should create test interface registry key") + require.NoError(t, testKey.Close(), "close test interface registry key") + defer func() { + assert.NoError(t, registry.DeleteKey(registry.LOCAL_MACHINE, interfacePath), "delete test interface registry key") + }() + + cfg := ®istryConfigurator{guid: testGUID} + config := HostDNSConfig{ + ServerIP: netip.MustParseAddr("100.64.0.1"), + RouteAll: true, + } + + require.NoError(t, cfg.applyDNSConfig(config, nil)) + + // RouteAll with no match domains and the switch set leaves nothing to write. + exists, err := registryKeyExists(fmt.Sprintf("%s-0", dnsPolicyConfigMatchPath)) + require.NoError(t, err) + assert.False(t, exists, "no rule should be written when the legacy env var is set") + + exists, err = registryKeyExists(dnsPolicyConfigExemptLocalPath) + require.NoError(t, err) + assert.False(t, exists, "no exemption without a claimed root namespace") +} + func registryKeyExists(path string) (bool, error) { k, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.QUERY_VALUE) if err != nil { @@ -106,9 +247,65 @@ func registryKeyExists(path string) (bool, error) { return true, nil } +// TestNRPTCleanupWithoutRuleCount verifies that rules written by a previous run +// are removed by a configurator that has no record of how many there are: an +// unclean exit loses the in-memory count and a clean disconnect deletes the +// persisted one, so cleanup cannot depend on either. +func TestNRPTCleanupWithoutRuleCount(t *testing.T) { + if testing.Short() { + t.Skip("skipping registry integration test in short mode") + } + + defer cleanupRegistryKeys(t) + cleanupRegistryKeys(t) + + testIP := netip.MustParseAddr("100.64.0.1") + + // 75 domains produce two indexed rules, as the current layout does + domains := make([]string, 75) + for i := range domains { + domains[i] = fmt.Sprintf(".domain%d.com", i+1) + } + + previousRun := ®istryConfigurator{} + require.NoError(t, previousRun.addDNSMatchPolicy(domains, testIP)) + + // the unsuffixed key an older version would have written + require.NoError(t, previousRun.configureDNSPolicy(dnsPolicyConfigMatchPath, []string{".legacy.example.com"}, testIP)) + + // a policy owned by someone else, which cleanup must not touch + foreignPath := DNSPolicyConfigRoot + `\DnsPolicyConfigTestForeign` + foreignKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, foreignPath, registry.SET_VALUE) + require.NoError(t, err, "Should create foreign policy key") + foreignKey.Close() + defer func() { + _ = registry.DeleteKey(registry.LOCAL_MACHINE, foreignPath) + }() + + require.Equal(t, 3, countNRPTRuleKeys(t), "Should have two indexed rules and the legacy one") + + // a configurator that never applied a DNS config, as one built after a + // restart or from a shutdown state without a count is + freshRun := ®istryConfigurator{} + require.NoError(t, freshRun.removeDNSMatchPolicies()) + + assert.Equal(t, 0, countNRPTRuleKeys(t), "Should remove every rule left by the previous run") + + exists, err := registryKeyExists(foreignPath) + require.NoError(t, err) + assert.True(t, exists, "Should not remove a policy that is not ours") +} + +func countNRPTRuleKeys(t *testing.T) int { + t.Helper() + + names, err := listNRPTRuleKeys(DNSPolicyConfigRoot) + require.NoError(t, err, "Should list NRPT rule keys") + return len(names) +} + func cleanupRegistryKeys(*testing.T) { - // Clean up more entries to account for batching tests with many domains - cfg := ®istryConfigurator{nrptEntryCount: 20} + cfg := ®istryConfigurator{} _ = cfg.removeDNSMatchPolicies() } @@ -125,7 +322,7 @@ func TestNRPTDomainBatching(t *testing.T) { // Create a test interface registry key so updateSearchDomains doesn't fail testGUID := "{12345678-1234-1234-1234-123456789ABC}" - interfacePath := `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\` + testGUID + interfacePath := InterfaceConfigPath + `\` + testGUID testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE) require.NoError(t, err, "Should create test interface registry key") testKey.Close() @@ -193,7 +390,7 @@ func TestNRPTDomainBatching(t *testing.T) { require.NoError(t, err) // Verify that exactly expectedRuleCount rules were created - assert.Equal(t, tc.expectedRuleCount, cfg.nrptEntryCount, + assert.Equal(t, tc.expectedRuleCount, countNRPTRuleKeys(t), "Should create %d NRPT rules for %d domains", tc.expectedRuleCount, tc.domainCount) // Verify all expected rules exist @@ -210,3 +407,130 @@ func TestNRPTDomainBatching(t *testing.T) { }) } } + +// TestRemoveEmptyGPOPolicyStore verifies that cleanup takes the GPO policy +// store itself with it once our rules are gone, since the store existing keeps +// the local one from being applied, and that a store with somebody else's rule +// in it is left alone. +func TestRemoveEmptyGPOPolicyStore(t *testing.T) { + if testing.Short() { + t.Skip("skipping registry integration test in short mode") + } + + t.Cleanup(func() { cleanupRegistryKeys(t) }) + cleanupRegistryKeys(t) + + testIP := netip.MustParseAddr("100.64.0.1") + cfg := ®istryConfigurator{gpo: true} + + // a store holding a rule of ours is kept, because the rule is still applied + require.NoError(t, cfg.addDNSMatchPolicy([]string{".example.com"}, testIP)) + exists, err := registryKeyExists(gpoDnsPolicyConfigMatchPath + "-0") + require.NoError(t, err) + require.True(t, exists, "Should write the rule to the GPO policy store") + + require.NoError(t, removeEmptyGPOPolicyStore()) + exists, err = registryKeyExists(GPODNSPolicyConfigRoot) + require.NoError(t, err) + assert.True(t, exists, "Should keep a policy store that still holds a rule") + + // once the rules are gone the store goes with them + require.NoError(t, cfg.removeDNSMatchPolicies()) + require.NoError(t, removeEmptyGPOPolicyStore()) + + exists, err = registryKeyExists(GPODNSPolicyConfigRoot) + require.NoError(t, err) + assert.False(t, exists, "Should remove the GPO policy store once it is empty") + + // A store is not ours to remove while somebody else has a rule in it. The + // rule is written volatile like our own: the rules above created the parent + // chain volatile, and Windows refuses a stable subkey under a volatile + // parent. + foreignRule := GPODNSPolicyConfigRoot + `\{2A3B4C5D-6E7F-4041-8283-84858687888A}` + foreignKey, _, err := winregistry.CreateVolatileKey(registry.LOCAL_MACHINE, foreignRule, registry.SET_VALUE) + require.NoError(t, err, "Should create a foreign GPO rule") + foreignKey.Close() + t.Cleanup(func() { + _ = registry.DeleteKey(registry.LOCAL_MACHINE, foreignRule) + _ = registry.DeleteKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot) + }) + + require.NoError(t, cfg.removeDNSMatchPolicies()) + require.NoError(t, removeEmptyGPOPolicyStore()) + + exists, err = registryKeyExists(foreignRule) + require.NoError(t, err) + assert.True(t, exists, "Should not remove a foreign rule") + exists, err = registryKeyExists(GPODNSPolicyConfigRoot) + require.NoError(t, err) + assert.True(t, exists, "Should keep a policy store that still holds a foreign rule") +} + +// TestDeleteInterfaceRegistryKeyPropertyTwice verifies that removing a value +// that is already gone, or one on an interface key that is, reports success. +// Teardown runs again after a failed cleanup, and the steps that follow this +// one have to be reached on that second run. +func TestDeleteInterfaceRegistryKeyPropertyTwice(t *testing.T) { + if testing.Short() { + t.Skip("skipping registry integration test in short mode") + } + + testGUID := "{12345678-1234-1234-1234-123456789ABC}" + interfacePath := InterfaceConfigPath + `\` + testGUID + testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE) + require.NoError(t, err, "Should create test interface registry key") + testKey.Close() + t.Cleanup(func() { + _ = registry.DeleteKey(registry.LOCAL_MACHINE, interfacePath) + }) + + cfg := ®istryConfigurator{guid: testGUID} + + require.NoError(t, cfg.setInterfaceRegistryKeyStringValue(interfaceConfigSearchListKey, "example.com")) + require.NoError(t, cfg.deleteInterfaceRegistryKeyProperty(interfaceConfigSearchListKey)) + assert.NoError(t, cfg.deleteInterfaceRegistryKeyProperty(interfaceConfigSearchListKey), + "Should report success for a value that is already gone") + + // and with the interface key itself gone, as it is once the adapter is + require.NoError(t, registry.DeleteKey(registry.LOCAL_MACHINE, interfacePath)) + assert.NoError(t, cfg.deleteInterfaceRegistryKeyProperty(interfaceConfigSearchListKey), + "Should report success when the interface key does not exist") +} + +// TestUseGPOPolicyStoreClearsEmptyStore verifies that the store is cleared +// before it is consulted, so an empty one left by an earlier run does not send +// this run's rules to the group policy store. A store somebody else has a rule +// in still decides where the rules go. +func TestUseGPOPolicyStoreClearsEmptyStore(t *testing.T) { + if testing.Short() { + t.Skip("skipping registry integration test in short mode") + } + + t.Cleanup(func() { cleanupRegistryKeys(t) }) + cleanupRegistryKeys(t) + + // the leftover an earlier run used to keep, which the client read as + // "group policy configures the NRPT" for every run after it + emptyStore, _, err := winregistry.CreateVolatileKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot, registry.SET_VALUE) + require.NoError(t, err, "Should create the GPO policy store") + emptyStore.Close() + + assert.False(t, useGPOPolicyStore(), "An empty store should not decide where the rules go") + exists, err := registryKeyExists(GPODNSPolicyConfigRoot) + require.NoError(t, err) + assert.False(t, exists, "Should clear the empty store before consulting it") + + foreignRule := GPODNSPolicyConfigRoot + `\{2A3B4C5D-6E7F-4041-8283-84858687888A}` + foreignKey, _, err := winregistry.CreateVolatileKey(registry.LOCAL_MACHINE, foreignRule, registry.SET_VALUE) + require.NoError(t, err, "Should create a foreign GPO rule") + foreignKey.Close() + t.Cleanup(func() { + _ = registry.DeleteKey(registry.LOCAL_MACHINE, foreignRule) + _ = registry.DeleteKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot) + }) + + assert.True(t, useGPOPolicyStore(), "A store holding a rule should decide where the rules go") + exists, err = registryKeyExists(GPODNSPolicyConfigRoot) + require.NoError(t, err) + assert.True(t, exists, "Should keep a store that holds a rule") +} diff --git a/client/internal/dns/interface_index.go b/client/internal/dns/interface_index.go new file mode 100644 index 000000000..9e7dca080 --- /dev/null +++ b/client/internal/dns/interface_index.go @@ -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 +} diff --git a/client/internal/dns/interface_index_test.go b/client/internal/dns/interface_index_test.go new file mode 100644 index 000000000..9b146398a --- /dev/null +++ b/client/internal/dns/interface_index_test.go @@ -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") + } +} diff --git a/client/internal/dns/local/local.go b/client/internal/dns/local/local.go index d0268186c..fef35fd41 100644 --- a/client/internal/dns/local/local.go +++ b/client/internal/dns/local/local.go @@ -6,6 +6,7 @@ import ( "fmt" "net" "net/netip" + "os" "slices" "strings" "sync" @@ -36,7 +37,43 @@ type resolver interface { // record is left alone (it points at something outside our mesh, e.g. // a non-peer upstream). type PeerConnectivity interface { - IsConnectedByIP(ip string) (known, connected bool) + IsConnectedByIP(ip netip.Addr) (known, connected bool) +} + +// PeerActivator wakes lazy-connection peers on demand. The local resolver calls +// it with the tunnel IPs an answer points at, so a peer that is idle (lazily +// disconnected) starts connecting at DNS-resolution time rather than racing the +// client's first request packet. nil disables warm-up. +type PeerActivator interface { + // ActivatePeersByIP triggers wake-up for the peer(s) owning addrs and blocks + // until one is connected or ctx (a short per-query budget) expires. It is a + // fast no-op for unknown or already-connected addresses. + ActivatePeersByIP(ctx context.Context, addrs []netip.Addr) +} + +const ( + defaultLazyWarmupTimeout = 2 * time.Second + envLazyWarmupTimeout = "NB_DNS_LAZY_WARMUP_TIMEOUT" +) + +// lazyWarmupTimeoutFromEnv returns the per-query budget for waking a +// lazy-connection peer a DNS answer points at. Tunable via +// NB_DNS_LAZY_WARMUP_TIMEOUT (a Go duration). Parsed once at construction time. +func lazyWarmupTimeoutFromEnv() time.Duration { + v := os.Getenv(envLazyWarmupTimeout) + if v == "" { + return defaultLazyWarmupTimeout + } + d, err := time.ParseDuration(v) + if err != nil { + log.Warnf("invalid %s value %q, using default %s: %v", envLazyWarmupTimeout, v, defaultLazyWarmupTimeout, err) + return defaultLazyWarmupTimeout + } + if d <= 0 { + log.Warnf("non-positive %s value %q, using default %s", envLazyWarmupTimeout, v, defaultLazyWarmupTimeout) + return defaultLazyWarmupTimeout + } + return d } type Resolver struct { @@ -51,6 +88,12 @@ type Resolver struct { // filter and preserves the legacy "return whatever is registered" // behaviour for callers that never wire a status source. peerConn PeerConnectivity + // peerActivator, when non-nil, is called at resolution time to warm the + // lazy connection to the peer(s) an answer points at. nil disables warm-up. + peerActivator PeerActivator + // warmupTimeout is the per-query budget for the lazy-connection warm-up + // wait, resolved from the environment once at construction time. + warmupTimeout time.Duration ctx context.Context cancel context.CancelFunc @@ -59,11 +102,12 @@ type Resolver struct { func NewResolver() *Resolver { ctx, cancel := context.WithCancel(context.Background()) return &Resolver{ - records: make(map[dns.Question][]dns.RR), - domains: make(map[domain.Domain]struct{}), - zones: make(map[domain.Domain]bool), - ctx: ctx, - cancel: cancel, + records: make(map[dns.Question][]dns.RR), + domains: make(map[domain.Domain]struct{}), + zones: make(map[domain.Domain]bool), + warmupTimeout: lazyWarmupTimeoutFromEnv(), + ctx: ctx, + cancel: cancel, } } @@ -76,6 +120,14 @@ func (d *Resolver) SetPeerConnectivity(p PeerConnectivity) { d.peerConn = p } +// SetPeerActivator wires the DNS-time lazy-connection warm-up. Pass nil to +// disable. Safe to call multiple times; the latest value wins. +func (d *Resolver) SetPeerActivator(a PeerActivator) { + d.mu.Lock() + defer d.mu.Unlock() + d.peerActivator = a +} + func (d *Resolver) MatchSubdomains() bool { return true } @@ -122,6 +174,9 @@ func (d *Resolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { replyMessage.RecursionAvailable = true result := d.lookupRecords(logger, question) + // Warm before filtering: activation flips a lazily-idle target to connected, + // which then lets it survive the disconnected-peer filter below. + d.warmLazyPeers(question, result.records) result.records = d.filterDisconnectedPeerAnswers(logger, question, result.records) replyMessage.Authoritative = !result.hasExternalData replyMessage.Answer = result.records @@ -495,8 +550,8 @@ func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns kept := make([]dns.RR, 0, len(records)) var dropped int for _, rr := range records { - ip := extractRecordIP(rr) - if ip == "" { + ip, ok := extractRecordAddr(rr) + if !ok { kept = append(kept, rr) continue } @@ -518,22 +573,57 @@ func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns return kept } -// extractRecordIP returns the dotted-decimal / colon-hex IP carried by -// an A or AAAA record, or "" for any other record type. -func extractRecordIP(rr dns.RR) string { +// warmLazyPeers triggers lazy-connection wake-up for the peers a resolved +// answer points at and waits briefly for one to connect, so the caller's first +// request doesn't race the connection establishment. Warm-up is scoped to +// match-only (non-authoritative) zones — the synthesized private-service zones +// and user-created zones whose records point at specific peers. The account's +// peer zone is authoritative, so plain peer-name lookups never trigger warm-up; +// otherwise resolving any peer's name would wake its idle connection, defeating +// laziness mesh-wide. No-op when no activator is wired (lazy connections +// disabled) or the answer carries no peer IPs. +func (d *Resolver) warmLazyPeers(question dns.Question, records []dns.RR) { + if len(records) < 2 { + return + } + d.mu.RLock() + activator := d.peerActivator + var nonAuth, found bool + if activator != nil { + nonAuth, found = d.findZone(question.Name) + } + d.mu.RUnlock() + if activator == nil || !found || !nonAuth { + return + } + + var addrs []netip.Addr + for _, rr := range records { + if addr, ok := extractRecordAddr(rr); ok { + addrs = append(addrs, addr) + } + } + if len(addrs) == 0 { + return + } + + ctx, cancel := context.WithTimeout(d.ctx, d.warmupTimeout) + defer cancel() + activator.ActivatePeersByIP(ctx, addrs) +} + +// extractRecordAddr returns the IP address carried by an A or AAAA record. +// ok is false for any other record type or a record with no address. +func extractRecordAddr(rr dns.RR) (netip.Addr, bool) { switch r := rr.(type) { case *dns.A: - if r.A == nil { - return "" - } - return r.A.String() + addr, ok := netip.AddrFromSlice(r.A) + return addr.Unmap(), ok case *dns.AAAA: - if r.AAAA == nil { - return "" - } - return r.AAAA.String() + addr, ok := netip.AddrFromSlice(r.AAAA) + return addr.Unmap(), ok } - return "" + return netip.Addr{}, false } // Update replaces all zones and their records diff --git a/client/internal/dns/local/local_test.go b/client/internal/dns/local/local_test.go index 9b7dac231..89e896c0a 100644 --- a/client/internal/dns/local/local_test.go +++ b/client/internal/dns/local/local_test.go @@ -37,8 +37,8 @@ type mockPeerConnectivity struct { byIP map[string]struct{ known, connected bool } } -func (m mockPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) { - v, ok := m.byIP[ip] +func (m mockPeerConnectivity) IsConnectedByIP(ip netip.Addr) (known, connected bool) { + v, ok := m.byIP[ip.String()] if !ok { return false, false } diff --git a/client/internal/dns/local/warmup_test.go b/client/internal/dns/local/warmup_test.go new file mode 100644 index 000000000..0e77aa963 --- /dev/null +++ b/client/internal/dns/local/warmup_test.go @@ -0,0 +1,204 @@ +package local + +import ( + "context" + "net" + "net/netip" + "sync" + "testing" + "time" + + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/dns/test" + nbdns "github.com/netbirdio/netbird/dns" +) + +// recordingActivator records the addresses it was asked to warm and returns +// immediately, so ServeDNS is not blocked by the test. +type recordingActivator struct { + mu sync.Mutex + called bool + addrs []netip.Addr +} + +func (r *recordingActivator) ActivatePeersByIP(_ context.Context, addrs []netip.Addr) { + r.mu.Lock() + defer r.mu.Unlock() + r.called = true + r.addrs = append(r.addrs, addrs...) +} + +func serveA(t *testing.T, resolver *Resolver, name string) *dns.Msg { + t.Helper() + var resp *dns.Msg + w := &test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }} + resolver.ServeDNS(w, new(dns.Msg).SetQuestion(name, dns.TypeA)) + return resp +} + +// serviceZone registers rec in a match-only (non-authoritative) zone, the shape +// the synthesized private-service zones arrive in. +func serviceZone(t *testing.T, resolver *Resolver, zone string, records ...nbdns.SimpleRecord) { + t.Helper() + resolver.Update([]nbdns.CustomZone{{ + Domain: zone, + Records: records, + NonAuthoritative: true, + }}) +} + +func TestLocalResolver_WarmsLazyPeerOnResolve(t *testing.T) { + // Warm-up fires only for multi-record answers (the HA / round-robin shape of + // the synthesized private-service zones), so register two peer targets. + const name = "svc.proxy.netbird.cloud." + recs := []nbdns.SimpleRecord{ + {Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"}, + {Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.8"}, + } + resolver := NewResolver() + serviceZone(t, resolver, "proxy.netbird.cloud", recs...) + + act := &recordingActivator{} + resolver.SetPeerActivator(act) + + resp := serveA(t, resolver, name) + require.NotNil(t, resp, "resolver must answer") + require.NotEmpty(t, resp.Answer, "answer must carry the A records") + + act.mu.Lock() + defer act.mu.Unlock() + assert.True(t, act.called, "activator must be invoked for a multi-record service-zone answer") + assert.Contains(t, act.addrs, netip.MustParseAddr("100.64.0.7"), "activator must receive the first peer IP") + assert.Contains(t, act.addrs, netip.MustParseAddr("100.64.0.8"), "activator must receive the second peer IP") +} + +func TestLocalResolver_NoWarmupForSingleRecord(t *testing.T) { + // A single-record answer does not trigger warm-up; the resolver only warms + // multi-record answers. + rec := nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"} + resolver := NewResolver() + serviceZone(t, resolver, "proxy.netbird.cloud", rec) + + act := &recordingActivator{} + resolver.SetPeerActivator(act) + + resp := serveA(t, resolver, rec.Name) + require.NotNil(t, resp, "resolver must answer") + require.NotEmpty(t, resp.Answer, "answer must carry the A record") + + act.mu.Lock() + defer act.mu.Unlock() + assert.False(t, act.called, "activator must not be invoked for a single-record answer") +} + +func TestLocalResolver_NoActivatorNoWarmup(t *testing.T) { + // With no activator wired the resolver behaves exactly as before. + rec := nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"} + resolver := NewResolver() + serviceZone(t, resolver, "proxy.netbird.cloud", rec) + + resp := serveA(t, resolver, rec.Name) + require.NotNil(t, resp, "resolver must still answer without an activator") + require.NotEmpty(t, resp.Answer, "answer must carry the A record") +} + +func TestLocalResolver_NoWarmupForMissingRecord(t *testing.T) { + // A query that resolves to nothing must not invoke the activator (no IPs). + resolver := NewResolver() + serviceZone(t, resolver, "proxy.netbird.cloud", + nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"}) + + act := &recordingActivator{} + resolver.SetPeerActivator(act) + + serveA(t, resolver, "absent.proxy.netbird.cloud.") + + act.mu.Lock() + defer act.mu.Unlock() + assert.False(t, act.called, "activator must not be invoked when there is no answer") +} + +func TestLocalResolver_NoWarmupInAuthoritativeZone(t *testing.T) { + // The account's peer zone is authoritative; resolving a peer's name there + // must not wake its lazy connection — warm-up is scoped to match-only + // (non-authoritative) zones such as the synthesized private-service zones. + // Use a multi-record answer so the authoritative-zone scoping is the only + // reason warm-up is skipped, not the single-record guard. + const name = "peer.netbird.cloud." + recs := []nbdns.SimpleRecord{ + {Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.9"}, + {Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.10"}, + } + resolver := NewResolver() + resolver.Update([]nbdns.CustomZone{{ + Domain: "netbird.cloud", + Records: recs, + }}) + + act := &recordingActivator{} + resolver.SetPeerActivator(act) + + resp := serveA(t, resolver, name) + require.NotNil(t, resp, "resolver must answer") + require.NotEmpty(t, resp.Answer, "answer must carry the A records") + + act.mu.Lock() + defer act.mu.Unlock() + assert.False(t, act.called, "activator must not be invoked for authoritative-zone answers") +} + +func TestLazyWarmupTimeoutFromEnv(t *testing.T) { + tests := []struct { + name string + value string + envSet bool + want time.Duration + }{ + {name: "unset uses default", want: defaultLazyWarmupTimeout}, + {name: "valid overrides", value: "5s", envSet: true, want: 5 * time.Second}, + {name: "invalid falls back", value: "not-a-duration", envSet: true, want: defaultLazyWarmupTimeout}, + {name: "negative falls back", value: "-1s", envSet: true, want: defaultLazyWarmupTimeout}, + {name: "zero falls back", value: "0s", envSet: true, want: defaultLazyWarmupTimeout}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envSet { + t.Setenv(envLazyWarmupTimeout, tt.value) + } + assert.Equal(t, tt.want, lazyWarmupTimeoutFromEnv()) + assert.Equal(t, tt.want, NewResolver().warmupTimeout, "constructor must resolve the timeout once") + }) + } +} + +func TestExtractRecordAddr(t *testing.T) { + t.Run("A record yields unmapped v4", func(t *testing.T) { + // net.ParseIP returns the 16-byte v4-in-v6 form, the same shape + // miekg/dns stores after parsing an A record; the extracted address + // must compare equal to a plain v4 netip.Addr. + addr, ok := extractRecordAddr(&dns.A{A: net.ParseIP("100.64.0.7")}) + require.True(t, ok) + assert.True(t, addr.Is4()) + assert.Equal(t, netip.MustParseAddr("100.64.0.7"), addr) + }) + + t.Run("AAAA record yields v6", func(t *testing.T) { + addr, ok := extractRecordAddr(&dns.AAAA{AAAA: net.ParseIP("fd00::1")}) + require.True(t, ok) + assert.Equal(t, netip.MustParseAddr("fd00::1"), addr) + }) + + t.Run("A record without address", func(t *testing.T) { + _, ok := extractRecordAddr(&dns.A{}) + assert.False(t, ok) + }) + + t.Run("non-address record", func(t *testing.T) { + _, ok := extractRecordAddr(&dns.CNAME{Target: "target.netbird.cloud."}) + assert.False(t, ok) + }) +} diff --git a/client/internal/dns/mgmt/mgmt_refresh_test.go b/client/internal/dns/mgmt/mgmt_refresh_test.go index 64a5342e2..0e3e6ab36 100644 --- a/client/internal/dns/mgmt/mgmt_refresh_test.go +++ b/client/internal/dns/mgmt/mgmt_refresh_test.go @@ -224,6 +224,7 @@ func TestResolver_StaleTriggersAsyncRefresh(t *testing.T) { } func TestResolver_ConcurrentStaleHitsCollapseRefresh(t *testing.T) { + semaphore := make(chan struct{}) r := NewResolver() chain := newFakeChain() chain.setAnswer("mgmt.example.com.", dns.TypeA, "10.0.0.2") @@ -239,7 +240,7 @@ func TestResolver_ConcurrentStaleHitsCollapseRefresh(t *testing.T) { break } } - time.Sleep(50 * time.Millisecond) // hold inflight long enough to collide + <-semaphore // block the call to force request collision } r.SetChainResolver(chain, 50) @@ -255,17 +256,17 @@ func TestResolver_ConcurrentStaleHitsCollapseRefresh(t *testing.T) { var wg sync.WaitGroup for i := 0; i < 50; i++ { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { queryA(t, r, "mgmt.example.com.") - }() + }) } + + assert.Eventually(t, func() bool { return inflight.Load() >= 1 }, 2*time.Second, 100*time.Millisecond) + + close(semaphore) wg.Wait() - waitFor(t, 2*time.Second, func() bool { - return inflight.Load() == 0 - }) + assert.Eventually(t, func() bool { return inflight.Load() == 0 }, 2*time.Second, 100*time.Millisecond) calls := chain.callCount("mgmt.example.com.", dns.TypeA) assert.LessOrEqual(t, calls, 2, "singleflight must collapse concurrent refreshes (got %d)", calls) diff --git a/client/internal/dns/mock_server.go b/client/internal/dns/mock_server.go index 31fedd9e5..b19862c2f 100644 --- a/client/internal/dns/mock_server.go +++ b/client/internal/dns/mock_server.go @@ -8,6 +8,7 @@ import ( "github.com/miekg/dns" dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config" + "github.com/netbirdio/netbird/client/internal/dns/local" nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/domain" @@ -92,6 +93,11 @@ func (m *MockServer) SetFirewall(Firewall) { // Mock implementation - no-op } +// SetPeerActivator mock implementation of SetPeerActivator from Server interface +func (m *MockServer) SetPeerActivator(local.PeerActivator) { + // Mock implementation - no-op +} + // BeginBatch mock implementation of BeginBatch from Server interface func (m *MockServer) BeginBatch() { // Mock implementation - no-op diff --git a/client/internal/dns/notifier.go b/client/internal/dns/notifier.go index 35cb6ff82..79d924a78 100644 --- a/client/internal/dns/notifier.go +++ b/client/internal/dns/notifier.go @@ -51,7 +51,5 @@ func (n *notifier) notify() { return } - go func(l listener.NetworkChangeListener) { - l.OnNetworkChanged("") - }(n.listener) + n.listener.OnNetworkChanged("") } diff --git a/client/internal/dns/response_writer_test.go b/client/internal/dns/response_writer_test.go index 857964406..bc8416029 100644 --- a/client/internal/dns/response_writer_test.go +++ b/client/internal/dns/response_writer_test.go @@ -4,7 +4,7 @@ import ( "net" "testing" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/google/gopacket" "github.com/google/gopacket/layers" "github.com/miekg/dns" diff --git a/client/internal/dns/server.go b/client/internal/dns/server.go index 7556c66cc..3af912792 100644 --- a/client/internal/dns/server.go +++ b/client/internal/dns/server.go @@ -82,6 +82,7 @@ type Server interface { PopulateManagementDomain(mgmtURL *url.URL) error SetRouteSources(selected, active func() route.HAMap) SetFirewall(Firewall) + SetPeerActivator(local.PeerActivator) } type nsGroupsByDomain struct { @@ -251,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 @@ -491,6 +492,13 @@ func (s *DefaultServer) SetFirewall(fw Firewall) { } } +// SetPeerActivator wires the DNS-time lazy-connection warm-up on the local +// resolver. Injected after the connection manager exists (it does not at +// DNS-server construction time). Pass nil to disable. +func (s *DefaultServer) SetPeerActivator(a local.PeerActivator) { + s.localResolver.SetPeerActivator(a) +} + // Stop stops the server func (s *DefaultServer) Stop() { s.ctxCancel() @@ -594,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 { @@ -678,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) @@ -1435,11 +1449,11 @@ type localPeerConnectivity struct { // IsConnectedByIP looks the IP up in the peerstore and surfaces both // the known and connected bits. Used by Resolver.filterDisconnectedPeerAnswers. -func (l localPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) { +func (l localPeerConnectivity) IsConnectedByIP(ip netip.Addr) (known, connected bool) { if l.status == nil { return false, false } - state, ok := l.status.PeerStateByIP(ip) + state, ok := l.status.PeerStateByIP(ip.String()) if !ok { return false, false } diff --git a/client/internal/dns/server_privileged_test.go b/client/internal/dns/server_privileged_test.go index a03aea169..a17044cf5 100644 --- a/client/internal/dns/server_privileged_test.go +++ b/client/internal/dns/server_privileged_test.go @@ -9,7 +9,7 @@ import ( "os" "testing" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/miekg/dns" "github.com/stretchr/testify/assert" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" diff --git a/client/internal/dns/server_test.go b/client/internal/dns/server_test.go index 96e55a354..0144a4a8b 100644 --- a/client/internal/dns/server_test.go +++ b/client/internal/dns/server_test.go @@ -423,7 +423,7 @@ func createWgInterfaceWithBind(t *testing.T) (*iface.WGIface, error) { return nil, err } - pf, err := uspfilter.Create(wgIface, false, flowLogger, iface.DefaultMTU) + pf, err := uspfilter.Create(uspfilter.Config{IFace: wgIface, FlowLogger: flowLogger, MTU: iface.DefaultMTU}) if err != nil { t.Fatalf("failed to create uspfilter: %v", err) return nil, err diff --git a/client/internal/dns/service_listener.go b/client/internal/dns/service_listener.go index 9c0e52af8..d65a727b1 100644 --- a/client/internal/dns/service_listener.go +++ b/client/internal/dns/service_listener.go @@ -6,6 +6,7 @@ import ( "net" "net/netip" "runtime" + "slices" "strconv" "sync" "time" @@ -17,17 +18,20 @@ import ( nberrors "github.com/netbirdio/netbird/client/errors" firewall "github.com/netbirdio/netbird/client/firewall/manager" - "github.com/netbirdio/netbird/client/internal/ebpf" - ebpfMgr "github.com/netbirdio/netbird/client/internal/ebpf/manager" ) const ( customPort = 5053 + // randomPortAttempts bounds the search for a port free on both protocols. + randomPortAttempts = 5 ) var ( defaultIP = netip.MustParseAddr("127.0.0.1") customIP = netip.MustParseAddr("127.0.0.153") + + // dnatProtocols are the protocols the port 53 redirect covers. + dnatProtocols = []firewall.Protocol{firewall.ProtocolUDP, firewall.ProtocolTCP} ) type serviceViaListener struct { @@ -40,9 +44,20 @@ type serviceViaListener struct { listenPort uint16 listenerIsRunning bool listenerFlagLock sync.Mutex - ebpfService ebpfMgr.Manager firewall Firewall - tcpDNATConfigured bool + // dnatRules holds the port 53 redirects that are installed and not yet + // removed, so a removal that fails can be retried. + dnatRules []dnatRule +} + +// dnatRule is a port 53 redirect as it was installed. The target is kept with +// the rule because the listener can come back on a different address or port, +// and a retried removal has to name the address and port the rule was added +// with, not the ones in use now. +type dnatRule struct { + protocol firewall.Protocol + ip netip.Addr + port uint16 } func newServiceViaListener(wgIface WGIface, customAddr *netip.AddrPort, fw Firewall) *serviceViaListener { @@ -112,34 +127,93 @@ func (s *serviceViaListener) Listen() error { } }() - // When eBPF redirects UDP port 53 to our listen port, TCP still needs - // a DNAT rule because eBPF only handles UDP. - if s.ebpfService != nil && s.firewall != nil && s.listenPort != DefaultPort { - if err := s.firewall.AddOutputDNAT(s.listenIP, firewall.ProtocolTCP, DefaultPort, s.listenPort); err != nil { - log.Warnf("failed to add DNS TCP DNAT rule, TCP DNS on port 53 will not work: %v", err) - } else { - s.tcpDNATConfigured = true - log.Infof("added DNS TCP DNAT rule: %s:%d -> %s:%d", s.listenIP, DefaultPort, s.listenIP, s.listenPort) - } + if s.listenPort != DefaultPort { + s.setupDNAT() } return nil } +// setupDNAT redirects port 53 to the port the DNS server actually listens on. +// Both protocols must be redirected or none: RuntimePort reports port 53 only +// while the full redirect is in place, so a half-configured redirect would +// advertise a resolver that answers over one protocol. +func (s *serviceViaListener) setupDNAT() { + if s.firewall == nil { + log.Errorf("no firewall manager available to redirect DNS port %d to %d, "+ + "clients pointed at %s will not reach the resolver", DefaultPort, s.listenPort, s.listenIP) + return + } + + // Clear whatever an earlier removal left behind first. Those rules can point + // at an address or port this listener no longer uses, and they are matched + // before anything added now, so adding a redirect on top of one would keep + // sending port 53 traffic to the previous listener while reporting the + // redirect as complete. The rules stay recorded for a later attempt. + if err := s.removeDNAT(); err != nil { + log.Errorf("failed to remove stale DNS DNAT rules, leaving port %d redirected to the previous listener: %v", + DefaultPort, err) + return + } + + for _, proto := range dnatProtocols { + if err := s.firewall.AddOutputDNAT(s.listenIP, proto, DefaultPort, s.listenPort); err != nil { + log.Errorf("failed to add DNS %s DNAT rule, DNS on port %d will not work: %v", + proto, DefaultPort, err) + if err := s.removeDNAT(); err != nil { + log.Warnf("failed to roll back DNS DNAT rules, retrying on stop: %v", err) + } + return + } + s.dnatRules = append(s.dnatRules, dnatRule{protocol: proto, ip: s.listenIP, port: s.listenPort}) + } + + log.Infof("added DNS DNAT rules: %s:%d -> %s:%d (UDP + TCP)", s.listenIP, DefaultPort, s.listenIP, s.listenPort) +} + +// removeDNAT removes every installed port 53 redirect. A rule whose removal +// fails stays recorded so a later setup or Stop retries it, rather than leaving +// port 53 pointing at a resolver that is no longer listening. +func (s *serviceViaListener) removeDNAT() error { + if s.firewall == nil { + return nil + } + + var merr *multierror.Error + var remaining []dnatRule + for _, rule := range s.dnatRules { + if err := s.firewall.RemoveOutputDNAT(rule.ip, rule.protocol, DefaultPort, rule.port); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove DNS %s DNAT rule for %s:%d: %w", + rule.protocol, rule.ip, rule.port, err)) + remaining = append(remaining, rule) + } + } + s.dnatRules = remaining + + return nberrors.FormatErrorOrNil(merr) +} + func (s *serviceViaListener) Stop() error { s.listenerFlagLock.Lock() defer s.listenerFlagLock.Unlock() + var merr *multierror.Error + + // Redirects are removed even when the listener is already stopped, so that + // a removal which failed earlier is retried instead of leaving port 53 + // pointing at a resolver that no longer listens. + if err := s.removeDNAT(); err != nil { + merr = multierror.Append(merr, err) + } + if !s.listenerIsRunning { - return nil + return nberrors.FormatErrorOrNil(merr) } s.listenerIsRunning = false ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - var merr *multierror.Error - if err := s.server.ShutdownContext(ctx); err != nil { merr = multierror.Append(merr, fmt.Errorf("stop DNS UDP server: %w", err)) } @@ -148,19 +222,6 @@ func (s *serviceViaListener) Stop() error { merr = multierror.Append(merr, fmt.Errorf("stop DNS TCP server: %w", err)) } - if s.tcpDNATConfigured && s.firewall != nil { - if err := s.firewall.RemoveOutputDNAT(s.listenIP, firewall.ProtocolTCP, DefaultPort, s.listenPort); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove DNS TCP DNAT rule: %w", err)) - } - s.tcpDNATConfigured = false - } - - if s.ebpfService != nil { - if err := s.ebpfService.FreeDNSFwd(); err != nil { - merr = multierror.Append(merr, fmt.Errorf("stop traffic forwarder: %w", err)) - } - } - return nberrors.FormatErrorOrNil(merr) } @@ -177,11 +238,23 @@ func (s *serviceViaListener) RuntimePort() int { s.listenerFlagLock.Lock() defer s.listenerFlagLock.Unlock() - if s.ebpfService != nil { + if s.redirectInstalled() { return DefaultPort - } else { - return int(s.listenPort) } + return int(s.listenPort) +} + +// redirectInstalled reports whether every protocol is redirected from port 53 +// to the address and port the listener currently serves. Rules left over from +// an earlier listener do not count. +func (s *serviceViaListener) redirectInstalled() bool { + for _, proto := range dnatProtocols { + current := dnatRule{protocol: proto, ip: s.listenIP, port: s.listenPort} + if !slices.Contains(s.dnatRules, current) { + return false + } + } + return true } func (s *serviceViaListener) RuntimeIP() netip.Addr { @@ -190,30 +263,29 @@ func (s *serviceViaListener) RuntimeIP() netip.Addr { // evalListenAddress figures out the listen address for the DNS server. // IPv4-only: all peers have a v4 overlay address, and DNS config points to v4. -// First checks port 53 on WG interface or lo, then tries eBPF on a random port, -// then falls back to port 5053. +// Prefers port 53 on the overlay interface or lo, so no redirect is needed at +// all; when it is taken it falls back to port 5053 and then to a random free +// port, both of which need the port 53 redirect set up by setupDNAT. func (s *serviceViaListener) evalListenAddress() (netip.Addr, uint16, error) { if s.customAddr != nil { return s.customAddr.Addr(), s.customAddr.Port(), nil } - ip, ok := s.testFreePort(DefaultPort) - if ok { + if ip, ok := s.testFreePort(DefaultPort); ok { return ip, DefaultPort, nil } - ebpfSrv, port, ok := s.tryToUseeBPF() - if ok { - s.ebpfService = ebpfSrv - return s.wgInterface.Address().IP, port, nil - } - - ip, ok = s.testFreePort(customPort) - if ok { + if ip, ok := s.testFreePort(customPort); ok { return ip, customPort, nil } - return netip.Addr{}, 0, fmt.Errorf("failed to find a free port for DNS server") + ip := s.wgInterface.Address().IP + port, err := s.randomFreePort(ip) + if err != nil { + return netip.Addr{}, 0, fmt.Errorf("find a free port for DNS server: %w", err) + } + + return ip, port, nil } func (s *serviceViaListener) testFreePort(port int) (netip.Addr, bool) { @@ -260,50 +332,25 @@ func (s *serviceViaListener) tryToBind(ip netip.Addr, port int) bool { return true } -// tryToUseeBPF decides whether to apply eBPF program to capture DNS traffic on port 53. -// This is needed because on some operating systems if we start a DNS server not on a default port 53, -// the domain name resolution won't work. So, in case we are running on Linux and picked a free -// port we should fall back to the eBPF solution that will capture traffic on port 53 and forward -// it to a local DNS server running on the chosen port. -func (s *serviceViaListener) tryToUseeBPF() (ebpfMgr.Manager, uint16, bool) { - if runtime.GOOS != "linux" { - return nil, 0, false +// randomFreePort returns a port that is free on ip for both UDP and TCP, since +// the DNS server binds both. The probe listeners are closed again, so the port +// is only likely, not guaranteed, to still be free when the server binds it. +func (s *serviceViaListener) randomFreePort(ip netip.Addr) (uint16, error) { + for range randomPortAttempts { + probeListener, err := net.ListenUDP("udp4", &net.UDPAddr{}) + if err != nil { + return 0, fmt.Errorf("bind random port: %w", err) + } + + port := uint16(probeListener.LocalAddr().(*net.UDPAddr).Port) + if err := probeListener.Close(); err != nil { + return 0, fmt.Errorf("free up probed port: %w", err) + } + + if s.tryToBind(ip, int(port)) { + return port, nil + } } - port, err := s.generateFreePort() //nolint:staticcheck,unused - if err != nil { - log.Warnf("failed to generate a free port for eBPF DNS forwarder server: %s", err) - return nil, 0, false - } - - ebpfSrv := ebpf.GetEbpfManagerInstance() - err = ebpfSrv.LoadDNSFwd(s.wgInterface.Address().IP, int(port)) - if err != nil { - log.Warnf("failed to load DNS forwarder eBPF program, error: %s", err) - return nil, 0, false - } - - return ebpfSrv, port, true -} - -func (s *serviceViaListener) generateFreePort() (uint16, error) { - ok := s.tryToBind(s.wgInterface.Address().IP, customPort) - if ok { - return customPort, nil - } - - udpAddr := net.UDPAddrFromAddrPort(netip.MustParseAddrPort("0.0.0.0:0")) - probeListener, err := net.ListenUDP("udp", udpAddr) - if err != nil { - log.Debugf("failed to bind random port for DNS: %s", err) - return 0, err - } - - addrPort := netip.MustParseAddrPort(probeListener.LocalAddr().String()) // might panic if address is incorrect - err = probeListener.Close() - if err != nil { - log.Debugf("failed to free up DNS port: %s", err) - return 0, err - } - return addrPort.Port(), nil + return 0, fmt.Errorf("no port free for UDP and TCP on %s after %d attempts", ip, randomPortAttempts) } diff --git a/client/internal/dns/service_listener_test.go b/client/internal/dns/service_listener_test.go index 90ef71d19..b158a79fd 100644 --- a/client/internal/dns/service_listener_test.go +++ b/client/internal/dns/service_listener_test.go @@ -1,6 +1,7 @@ package dns import ( + "errors" "fmt" "net" "net/netip" @@ -10,6 +11,8 @@ import ( "github.com/miekg/dns" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + firewall "github.com/netbirdio/netbird/client/firewall/manager" ) func TestServiceViaListener_TCPAndUDP(t *testing.T) { @@ -84,3 +87,133 @@ func TestServiceViaListener_TCPAndUDP(t *testing.T) { require.NotEmpty(t, tcpResp.Answer) assert.Contains(t, tcpResp.Answer[0].String(), "192.0.2.1", "TCP response should contain expected IP") } + +type dnatCall struct { + rule dnatRule + added bool +} + +// fakeFirewall records DNAT calls and fails the ones named in addErrs/removeErrs. +type fakeFirewall struct { + calls []dnatCall + addErrs map[firewall.Protocol]error + removeErrs map[firewall.Protocol]error +} + +func (f *fakeFirewall) AddOutputDNAT(ip netip.Addr, protocol firewall.Protocol, _, translatedPort uint16) error { + if err := f.addErrs[protocol]; err != nil { + return err + } + f.calls = append(f.calls, dnatCall{rule: dnatRule{protocol: protocol, ip: ip, port: translatedPort}, added: true}) + return nil +} + +func (f *fakeFirewall) RemoveOutputDNAT(ip netip.Addr, protocol firewall.Protocol, _, translatedPort uint16) error { + if err := f.removeErrs[protocol]; err != nil { + return err + } + f.calls = append(f.calls, dnatCall{rule: dnatRule{protocol: protocol, ip: ip, port: translatedPort}}) + return nil +} + +func newDNATTestService(fw Firewall) *serviceViaListener { + return &serviceViaListener{ + listenIP: netip.MustParseAddr("100.64.0.1"), + listenPort: customPort, + firewall: fw, + } +} + +func TestSetupDNAT_BothProtocols(t *testing.T) { + svc := newDNATTestService(&fakeFirewall{}) + + svc.setupDNAT() + + assert.Len(t, svc.dnatRules, len(dnatProtocols)) + assert.Equal(t, DefaultPort, svc.RuntimePort(), "port 53 is advertised once both redirects are installed") +} + +func TestSetupDNAT_RollsBackPartialRedirect(t *testing.T) { + fw := &fakeFirewall{addErrs: map[firewall.Protocol]error{firewall.ProtocolTCP: errors.New("nftables busy")}} + svc := newDNATTestService(fw) + + svc.setupDNAT() + + assert.Empty(t, svc.dnatRules, "the UDP redirect installed before the failure must be rolled back") + assert.Equal(t, int(svc.listenPort), svc.RuntimePort(), "an incomplete redirect must not advertise port 53") + udp := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: svc.listenPort} + assert.Contains(t, fw.calls, dnatCall{rule: udp}, "UDP removal should have been attempted") +} + +// A rollback that fails must keep the rule recorded, so port 53 is not left +// redirected to a resolver that no longer listens. +func TestStop_RetriesFailedDNATRemoval(t *testing.T) { + fw := &fakeFirewall{ + addErrs: map[firewall.Protocol]error{firewall.ProtocolTCP: errors.New("nftables busy")}, + removeErrs: map[firewall.Protocol]error{firewall.ProtocolUDP: errors.New("nftables busy")}, + } + svc := newDNATTestService(fw) + + svc.setupDNAT() + udp := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: svc.listenPort} + require.Equal(t, []dnatRule{udp}, svc.dnatRules, "a failed rollback keeps the rule for a later retry") + + require.Error(t, svc.Stop(), "the failing removal should be reported") + require.Equal(t, []dnatRule{udp}, svc.dnatRules) + + delete(fw.removeErrs, firewall.ProtocolUDP) + require.NoError(t, svc.Stop(), "a later stop retries the removal") + assert.Empty(t, svc.dnatRules) +} + +// A stale rule that cannot be removed is matched before anything added now, so +// no new redirect may be installed on top of it and port 53 must not be +// advertised as reaching this listener. +func TestSetupDNAT_AbortsWhileStaleRuleRemains(t *testing.T) { + fw := &fakeFirewall{removeErrs: map[firewall.Protocol]error{firewall.ProtocolUDP: errors.New("nftables busy")}} + svc := newDNATTestService(fw) + stalePort := svc.listenPort + + svc.setupDNAT() + require.Error(t, svc.Stop()) + staleUDP := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: stalePort} + require.Equal(t, []dnatRule{staleUDP}, svc.dnatRules) + + svc.listenPort = stalePort + 1 + fw.calls = nil + + svc.setupDNAT() + + assert.Equal(t, []dnatRule{staleUDP}, svc.dnatRules, "the stale rule stays recorded for a later attempt") + for _, call := range fw.calls { + assert.False(t, call.added, "no redirect may be installed while a stale one is still in place") + } + assert.Equal(t, int(svc.listenPort), svc.RuntimePort(), "port 53 must not be advertised") +} + +// A rule left behind by a failed removal must be removed with the address and +// port it was installed with, even when the listener has since moved to another +// port, and it must not count towards the redirect the new listener advertises. +func TestSetupDNAT_ClearsStaleRuleAfterPortChange(t *testing.T) { + fw := &fakeFirewall{removeErrs: map[firewall.Protocol]error{firewall.ProtocolUDP: errors.New("nftables busy")}} + svc := newDNATTestService(fw) + stalePort := svc.listenPort + + svc.setupDNAT() + require.Error(t, svc.Stop()) + staleUDP := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: stalePort} + require.Equal(t, []dnatRule{staleUDP}, svc.dnatRules) + + delete(fw.removeErrs, firewall.ProtocolUDP) + svc.listenPort = stalePort + 1 + fw.calls = nil + + svc.setupDNAT() + + assert.Contains(t, fw.calls, dnatCall{rule: staleUDP}, "the stale rule must be removed with its original port") + assert.Len(t, svc.dnatRules, len(dnatProtocols)) + assert.Equal(t, DefaultPort, svc.RuntimePort(), "the new listener is fully redirected") + for _, rule := range svc.dnatRules { + assert.Equal(t, svc.listenPort, rule.port, "only rules for the current listener remain") + } +} diff --git a/client/internal/dns/unclean_shutdown_windows.go b/client/internal/dns/unclean_shutdown_windows.go index 24a9eca50..ab0b2cc63 100644 --- a/client/internal/dns/unclean_shutdown_windows.go +++ b/client/internal/dns/unclean_shutdown_windows.go @@ -5,9 +5,8 @@ import ( ) type ShutdownState struct { - Guid string - GPO bool - NRPTEntryCount int + Guid string + GPO bool } func (s *ShutdownState) Name() string { @@ -16,9 +15,8 @@ func (s *ShutdownState) Name() string { func (s *ShutdownState) Cleanup() error { manager := ®istryConfigurator{ - guid: s.Guid, - gpo: s.GPO, - nrptEntryCount: s.NRPTEntryCount, + guid: s.Guid, + gpo: s.GPO, } if err := manager.restoreUncleanShutdownDNS(); err != nil { diff --git a/client/internal/dns/upstream_ios.go b/client/internal/dns/upstream_ios.go index b989bf0f9..793d87fca 100644 --- a/client/internal/dns/upstream_ios.go +++ b/client/internal/dns/upstream_ios.go @@ -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 -} diff --git a/client/internal/dns_peer_activator.go b/client/internal/dns_peer_activator.go new file mode 100644 index 000000000..c283d6251 --- /dev/null +++ b/client/internal/dns_peer_activator.go @@ -0,0 +1,76 @@ +package internal + +import ( + "context" + "net/netip" + "time" + + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/peerstore" +) + +const dnsActivationPollInterval = 50 * time.Millisecond + +// dnsPeerActivator wakes lazy-connection peers from the DNS resolution path. It +// implements dns/local.PeerActivator. DNS queries run on their own goroutines, +// so it only touches state that is safe for concurrent use — ConnMgr.ActivatePeer, +// peerstore.Store and peer.Status — and never takes the engine's syncMsgMux, +// keeping DNS resolution from contending with network-map processing. +type dnsPeerActivator struct { + connMgr *ConnMgr + peerStore *peerstore.Store + status *peer.Status + // ctx is the engine's long-lived context. The connection dial is tied to it + // (not the per-query DNS wait budget) so a handshake that outlasts the wait + // still completes in the background rather than being cancelled at the deadline. + ctx context.Context +} + +// ActivatePeersByIP triggers wake-up for the peer(s) owning addrs and waits +// until one is connected or ctx (the per-query DNS wait budget) expires. +// Activation itself is tied to the engine's long-lived context so the dial +// survives a wait that times out. Unknown or already-connected addresses are +// skipped, so the steady-state (warm) path adds no latency. +func (a *dnsPeerActivator) ActivatePeersByIP(ctx context.Context, addrs []netip.Addr) { + if a == nil || a.connMgr == nil { + return + } + + var pending []string + for _, addr := range addrs { + ip := addr.String() + st, ok := a.status.PeerStateByIP(ip) + if !ok || st.ConnStatus == peer.StatusConnected { + continue + } + conn, ok := a.peerStore.PeerConn(st.PubKey) + if !ok { + continue + } + a.connMgr.ActivatePeer(a.ctx, conn) + pending = append(pending, ip) + } + + if len(pending) == 0 { + return + } + a.waitConnected(ctx, pending) +} + +// waitConnected blocks until any of ips reports a connected peer or ctx expires. +func (a *dnsPeerActivator) waitConnected(ctx context.Context, ips []string) { + ticker := time.NewTicker(dnsActivationPollInterval) + defer ticker.Stop() + for { + for _, ip := range ips { + if st, ok := a.status.PeerStateByIP(ip); ok && st.ConnStatus == peer.StatusConnected { + return + } + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} diff --git a/client/internal/dns_peer_activator_test.go b/client/internal/dns_peer_activator_test.go new file mode 100644 index 000000000..8c3b75e59 --- /dev/null +++ b/client/internal/dns_peer_activator_test.go @@ -0,0 +1,129 @@ +package internal + +import ( + "context" + "net/netip" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/peerstore" +) + +func newTestPeerConn(t *testing.T, key string) *peer.Conn { + t.Helper() + conn, err := peer.NewConn(peer.ConnConfig{ + Key: key, + LocalKey: "local", + WgConfig: peer.WgConfig{ + AllowedIps: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")}, + }, + }, peer.ServiceDependencies{}) + require.NoError(t, err) + return conn +} + +func newTestDNSPeerActivator(t *testing.T) (*dnsPeerActivator, *peer.Status, *peerstore.Store) { + t.Helper() + status := peer.NewRecorder("https://mgm") + store := peerstore.NewConnStore() + // ConnMgr without Start: the lazy manager is nil, so ActivatePeer is a + // no-op — these tests exercise the activator's skip/wait logic. + connMgr := NewConnMgr(&EngineConfig{}, status, store, nil) + return &dnsPeerActivator{ + connMgr: connMgr, + peerStore: store, + status: status, + ctx: context.Background(), + }, status, store +} + +func TestDNSPeerActivator_NilSafe(t *testing.T) { + var a *dnsPeerActivator + a.ActivatePeersByIP(context.Background(), []netip.Addr{netip.MustParseAddr("100.64.0.1")}) +} + +// TestDNSPeerActivator_SkipsUnknownAndConnectedPeers verifies the steady-state +// (warm) path adds no latency: already-connected and unknown addresses never +// enter the wait loop. +func TestDNSPeerActivator_SkipsUnknownAndConnectedPeers(t *testing.T) { + a, status, store := newTestDNSPeerActivator(t) + + require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", "fd00::1")) + require.NoError(t, status.UpdatePeerState(peer.State{PubKey: "peerA", ConnStatus: peer.StatusConnected})) + store.AddPeerConn("peerA", newTestPeerConn(t, "peerA")) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + start := time.Now() + a.ActivatePeersByIP(ctx, []netip.Addr{ + netip.MustParseAddr("100.64.0.1"), // known, connected -> skipped + netip.MustParseAddr("fd00::1"), // known via IPv6, connected -> skipped + netip.MustParseAddr("100.64.0.99"), // unknown -> skipped + }) + require.Less(t, time.Since(start), time.Second, "no pending peer must mean no wait") +} + +// TestDNSPeerActivator_WaitsForPendingPeerToConnect verifies the wait loop +// returns as soon as a pending peer reports connected, well before the +// per-query budget expires. +func TestDNSPeerActivator_WaitsForPendingPeerToConnect(t *testing.T) { + a, status, store := newTestDNSPeerActivator(t) + + require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", "")) + store.AddPeerConn("peerA", newTestPeerConn(t, "peerA")) + + go func() { + time.Sleep(150 * time.Millisecond) + _ = status.UpdatePeerState(peer.State{PubKey: "peerA", ConnStatus: peer.StatusConnected}) + }() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + start := time.Now() + a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")}) + elapsed := time.Since(start) + + require.GreaterOrEqual(t, elapsed, 100*time.Millisecond, "must wait for the pending peer") + require.Less(t, elapsed, 5*time.Second, "must return on connect, not at the deadline") +} + +// TestDNSPeerActivator_ReturnsAtBudgetWhenPeerStaysIdle verifies a peer that +// never connects releases the DNS response at the per-query budget instead of +// blocking it indefinitely. +func TestDNSPeerActivator_ReturnsAtBudgetWhenPeerStaysIdle(t *testing.T) { + a, status, store := newTestDNSPeerActivator(t) + + require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", "")) + store.AddPeerConn("peerA", newTestPeerConn(t, "peerA")) + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + start := time.Now() + a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")}) + elapsed := time.Since(start) + + require.GreaterOrEqual(t, elapsed, 250*time.Millisecond, "must wait out the budget for a pending peer") + require.Less(t, elapsed, 5*time.Second, "must not block past the budget") +} + +// TestDNSPeerActivator_NoWaitWithoutPeerConn verifies a known-but-idle peer +// with no connection object in the store is not waited on: there is nothing to +// activate, so waiting could only ever time out. +func TestDNSPeerActivator_NoWaitWithoutPeerConn(t *testing.T) { + a, status, _ := newTestDNSPeerActivator(t) + + require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", "")) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + start := time.Now() + a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")}) + require.Less(t, time.Since(start), time.Second, "peer without a conn must not be waited on") +} diff --git a/client/internal/dns_test.go b/client/internal/dns_test.go index e15cc8fb7..031431efe 100644 --- a/client/internal/dns_test.go +++ b/client/internal/dns_test.go @@ -8,7 +8,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/netbirdio/netbird/client/iface/wgaddr" nbdns "github.com/netbirdio/netbird/dns" + mgmProto "github.com/netbirdio/netbird/shared/management/proto" ) func TestCreatePTRRecord_IPv4(t *testing.T) { @@ -136,3 +138,88 @@ func TestAddReverseZone_IPv6(t *testing.T) { assert.Len(t, reverseZone.Records, 1) assert.Equal(t, int(dns.TypePTR), reverseZone.Records[0].Type) } + +// TestToDNSConfig_ZoneFlagsPreserved pins the per-zone NonAuthoritative flag +// through the legacy DNSConfig path. A non-authoritative zone is match-only: +// the local resolver falls through to the upstream for an in-zone name it does +// not define. The built-in peer zone is the authoritative one and must stay +// that way, so the flag has to travel per zone rather than be derived. +func TestToDNSConfig_ZoneFlagsPreserved(t *testing.T) { + config := toDNSConfig(&mgmProto.DNSConfig{ + ServiceEnable: true, + CustomZones: []*mgmProto.CustomZone{ + { + Domain: "netbird.cloud.", + Records: []*mgmProto.SimpleRecord{ + {Name: "peer1.netbird.cloud.", Type: int64(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.1"}, + }, + }, + { + Domain: "corp.internal.", + NonAuthoritative: true, + SearchDomainDisabled: true, + Records: []*mgmProto.SimpleRecord{ + {Name: "db.corp.internal.", Type: int64(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.10.0.5"}, + }, + }, + }, + }, wgaddr.Address{ + IP: netip.MustParseAddr("100.64.0.1"), + Network: netip.MustParsePrefix("100.64.0.0/16"), + }) + + zones := make(map[string]nbdns.CustomZone, len(config.CustomZones)) + for _, zone := range config.CustomZones { + zones[zone.Domain] = zone + } + + peerZone, ok := zones["netbird.cloud."] + require.True(t, ok, "peer zone must survive") + assert.False(t, peerZone.NonAuthoritative, "the built-in peer zone owns the account domain and stays authoritative") + + accountZone, ok := zones["corp.internal."] + require.True(t, ok, "account zone must survive") + assert.True(t, accountZone.NonAuthoritative, "an account zone stays match-only, else undefined in-zone names get black-holed") + assert.True(t, accountZone.SearchDomainDisabled) +} + +// TestToDNSConfig_SingleZoneForcedAuthoritative pins the compatibility clause +// in toDNSConfig: a config carrying exactly one zone is treated as +// authoritative no matter what the server said, because servers that predate +// the NonAuthoritative field send only the peer FQDN zone. +// +// The clause can only ever downgrade an explicit true to false, so a server +// that legitimately sends a single non-authoritative zone — an account whose +// only zone is a custom one, with no peer records to build the built-in zone +// from — gets that zone's whole apex black-holed on the client. Real accounts +// always carry the peer zone alongside, which is why this is latent. Narrowing +// it needs a way to tell "unset" from "false" on the wire, or the account +// domain passed down here; until then this test states the contract so a +// change to it is deliberate. +func TestToDNSConfig_SingleZoneForcedAuthoritative(t *testing.T) { + config := toDNSConfig(&mgmProto.DNSConfig{ + ServiceEnable: true, + CustomZones: []*mgmProto.CustomZone{ + { + Domain: "corp.internal.", + NonAuthoritative: true, + Records: []*mgmProto.SimpleRecord{ + {Name: "db.corp.internal.", Type: int64(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.10.0.5"}, + }, + }, + }, + }, wgaddr.Address{ + IP: netip.MustParseAddr("100.64.0.1"), + Network: netip.MustParsePrefix("100.64.0.0/16"), + }) + + require.NotEmpty(t, config.CustomZones) + assert.Equal(t, "corp.internal.", config.CustomZones[0].Domain) + assert.False(t, config.CustomZones[0].NonAuthoritative, + "a lone zone is forced authoritative for pre-NonAuthoritative servers") + + // The reverse zone the config gains afterwards must not feed back into the + // decision: the compat gate counts the zones the server sent. + require.Len(t, config.CustomZones, 2, "a reverse zone is appended for the overlay prefix") + assert.Equal(t, "64.100.in-addr.arpa.", config.CustomZones[1].Domain) +} diff --git a/client/internal/dnsfwd/forwarder.go b/client/internal/dnsfwd/forwarder.go index b7e5a10e3..e3cb597be 100644 --- a/client/internal/dnsfwd/forwarder.go +++ b/client/internal/dnsfwd/forwarder.go @@ -54,12 +54,20 @@ type DNSForwarder struct { ttl uint32 statusRecorder *peer.Status - dnsServer *dns.Server - mux *dns.ServeMux - tcpServer *dns.Server - tcpMux *dns.ServeMux + mux *dns.ServeMux + tcpMux *dns.ServeMux - mutex sync.RWMutex + mutex sync.RWMutex + // closed records that Close has run, so a Listen still in flight does not + // go on to serve sockets nobody will shut down. + closed bool + // The sockets are kept alongside the servers because closing them is the + // only stop that always works: a server whose ActivateAndServe has not run + // yet refuses to shut down, and would otherwise start serving afterwards. + udpConn net.PacketConn + tcpLn net.Listener + dnsServer *dns.Server + tcpServer *dns.Server fwdEntries []*ForwarderEntry firewall firewaller resolver resolver @@ -106,7 +114,7 @@ func (f *DNSForwarder) Listen(entries []*ForwarderEntry) error { mux := dns.NewServeMux() f.mux = mux mux.HandleFunc(".", f.handleDNSQueryUDP) - f.dnsServer = &dns.Server{ + dnsServer := &dns.Server{ PacketConn: udpLn, Handler: mux, } @@ -114,22 +122,32 @@ func (f *DNSForwarder) Listen(entries []*ForwarderEntry) error { tcpMux := dns.NewServeMux() f.tcpMux = tcpMux tcpMux.HandleFunc(".", f.handleDNSQueryTCP) - f.tcpServer = &dns.Server{ + tcpServer := &dns.Server{ Listener: tcpLn, Handler: tcpMux, } - f.UpdateDomains(entries) + if !f.publish(udpLn, tcpLn, dnsServer, tcpServer, entries) { + log.Infof("DNS forwarder on %s was closed before it started serving", addrDesc) + if err := udpLn.Close(); err != nil { + log.Debugf("close UDP listener of a closed forwarder: %v", err) + } + if err := tcpLn.Close(); err != nil { + log.Debugf("close TCP listener of a closed forwarder: %v", err) + } + return nil + } + log.Debugf("DNS forwarder serving %d domains", len(entries)) errCh := make(chan error, 2) go func() { log.Infof("DNS UDP listener running on %s", addrDesc) - errCh <- f.dnsServer.ActivateAndServe() + errCh <- dnsServer.ActivateAndServe() }() go func() { log.Infof("DNS TCP listener running on %s", addrDesc) - errCh <- f.tcpServer.ActivateAndServe() + errCh <- tcpServer.ActivateAndServe() }() return <-errCh @@ -151,6 +169,46 @@ func (f *DNSForwarder) createTCPListener(netstackNet *netstack.Net) (net.Listene return net.ListenTCP("tcp", net.TCPAddrFromAddrPort(f.listenAddress)) } +// publish hands the sockets, servers and entries to the forwarder so Close can +// reach them and Domains can report them, and says whether serving may begin. +// Listen runs on its own goroutine, so a Close can arrive before it gets this +// far; false means the caller must close what it created instead of serving on +// it. +// +// The entries go in under the same lock rather than afterwards. Anything that +// reads them in between would otherwise see a forwarder that is listening and +// serves no domain, which for a caller rebuilding one means it comes back +// refusing every routed query. +func (f *DNSForwarder) publish( + udpConn net.PacketConn, + tcpLn net.Listener, + dnsServer, tcpServer *dns.Server, + entries []*ForwarderEntry, +) bool { + f.mutex.Lock() + defer f.mutex.Unlock() + + if f.closed { + return false + } + + f.udpConn = udpConn + f.tcpLn = tcpLn + f.dnsServer = dnsServer + f.tcpServer = tcpServer + f.fwdEntries = entries + return true +} + +// Domains returns the entries currently being served. The slice is replaced +// wholesale by UpdateDomains rather than mutated, so the caller may read it but +// must not write to it. +func (f *DNSForwarder) Domains() []*ForwarderEntry { + f.mutex.RLock() + defer f.mutex.RUnlock() + return f.fwdEntries +} + func (f *DNSForwarder) UpdateDomains(entries []*ForwarderEntry) { f.mutex.Lock() defer f.mutex.Unlock() @@ -189,19 +247,45 @@ func (f *DNSForwarder) removeStaleCacheEntries(oldEntries, newEntries []*Forward } func (f *DNSForwarder) Close(ctx context.Context) error { + // Marked closed under the lock so a Listen that has not published its + // servers yet gives up instead of racing this shutdown. The shutdowns + // themselves block, so they run outside it. + f.mutex.Lock() + f.closed = true + dnsServer, tcpServer := f.dnsServer, f.tcpServer + udpConn, tcpLn := f.udpConn, f.tcpLn + f.mutex.Unlock() + var result *multierror.Error - if f.dnsServer != nil { - if err := f.dnsServer.ShutdownContext(ctx); err != nil { + if dnsServer != nil { + if err := shutdownServer(ctx, dnsServer); err != nil { result = multierror.Append(result, fmt.Errorf("UDP shutdown: %w", err)) } } - if f.tcpServer != nil { - if err := f.tcpServer.ShutdownContext(ctx); err != nil { + if tcpServer != nil { + if err := shutdownServer(ctx, tcpServer); err != nil { result = multierror.Append(result, fmt.Errorf("TCP shutdown: %w", err)) } } + // The sockets are closed even when the shutdowns above reported nothing to + // do. A server that has been published but has not reached + // ActivateAndServe refuses to shut down, and closing what it was about to + // serve on is what stops it: the alternative is a listener still answering + // on an interface that has gone away. A shutdown that did run has already + // closed these, so the second close is expected to fail. + if udpConn != nil { + if err := udpConn.Close(); err != nil { + log.Debugf("close UDP socket of the DNS forwarder: %v", err) + } + } + if tcpLn != nil { + if err := tcpLn.Close(); err != nil { + log.Debugf("close TCP socket of the DNS forwarder: %v", err) + } + } + return nberrors.FormatErrorOrNil(result) } @@ -514,3 +598,16 @@ func attachEDE(resp *dns.Msg, code uint16, text string) { } opt.Option = append(opt.Option, &dns.EDNS0_EDE{InfoCode: code, ExtraText: text}) } + +// shutdownServer shuts a server down gracefully, treating "never started" as +// success. A server that was published but has not reached ActivateAndServe +// has nothing to wind down, and the caller closes its socket regardless, which +// is what actually stops it. dns exports no sentinel for this, so the message +// is all there is to match on. +func shutdownServer(ctx context.Context, server *dns.Server) error { + err := server.ShutdownContext(ctx) + if err == nil || strings.Contains(err.Error(), "server not started") { + return nil + } + return err +} diff --git a/client/internal/dnsfwd/forwarder_test.go b/client/internal/dnsfwd/forwarder_test.go index c69a9166e..a64ba80e7 100644 --- a/client/internal/dnsfwd/forwarder_test.go +++ b/client/internal/dnsfwd/forwarder_test.go @@ -1238,3 +1238,55 @@ func TestDNSForwarder_EmptyQuery(t *testing.T) { assert.Nil(t, mockWriter.GetLastResponse(), "Should not write response for empty query") } + +// TestDNSForwarder_ClosedBeforeItServes covers Listen reaching the point of +// serving after the forwarder has already been closed. Listen runs on its own +// goroutine, so it can get there late, and a socket it starts serving then is +// one nothing will ever close: on Android it keeps answering on an interface +// that has been replaced. The close is sequenced first here rather than raced, +// which pins the same state deterministically. +func TestDNSForwarder_ClosedBeforeItServes(t *testing.T) { + f := NewDNSForwarder(netip.MustParseAddrPort("127.0.0.1:0"), 60, nil, nil, nil) + + require.NoError(t, f.Close(context.Background()), "closing a forwarder that never started") + + done := make(chan error, 1) + go func() { done <- f.Listen(nil) }() + + select { + case err := <-done: + assert.NoError(t, err, "a closed forwarder should give up quietly, not serve") + case <-time.After(5 * time.Second): + t.Fatal("Listen went on to serve after the forwarder was closed") + } +} + +// TestDNSForwarder_CloseStopsUnactivatedServers covers the window between +// Listen publishing its servers and reaching ActivateAndServe. A server that +// has not been activated refuses to shut down, so Close has to close the +// sockets itself or they are left serving. +func TestDNSForwarder_CloseStopsUnactivatedServers(t *testing.T) { + f := NewDNSForwarder(netip.MustParseAddrPort("127.0.0.1:0"), 60, nil, nil, nil) + + udpConn, err := f.createUDPListener(nil) + require.NoError(t, err, "create UDP listener") + tcpLn, err := f.createTCPListener(nil) + require.NoError(t, err, "create TCP listener") + + // Published but deliberately never activated, which is the state Listen is + // in for the moment before it starts serving. + require.True(t, f.publish(udpConn, tcpLn, &dns.Server{PacketConn: udpConn}, &dns.Server{Listener: tcpLn}, nil), + "publishing to an open forwarder") + + tcpAddr := tcpLn.Addr().String() + require.NoError(t, f.Close(context.Background()), "close should report no error for servers it could not shut down") + + _, err = tcpLn.Accept() + assert.Error(t, err, "the TCP socket should be closed after Close") + + conn, err := net.DialTimeout("tcp", tcpAddr, time.Second) + if err == nil { + _ = conn.Close() + t.Fatal("the forwarder is still accepting connections after Close") + } +} diff --git a/client/internal/dnsfwd/manager.go b/client/internal/dnsfwd/manager.go index c4c16cd3f..1c62e908d 100644 --- a/client/internal/dnsfwd/manager.go +++ b/client/internal/dnsfwd/manager.go @@ -3,7 +3,6 @@ package dnsfwd import ( "context" "fmt" - "net" "net/netip" "os" "strconv" @@ -101,7 +100,7 @@ func (m *Manager) Start(fwdEntries []*ForwarderEntry) error { m.dnsForwarder = NewDNSForwarder(listenAddress, dnsTTL, m.firewall, m.statusRecorder, m.wgIface) go func() { - if err := m.dnsForwarder.Listen(fwdEntries); err != nil { + if err := m.dnsForwarder.Listen(fwdEntries); err != nil { //nolint:staticcheck // todo handle close error if it is exists log.Errorf("failed to start DNS forwarder, err: %v", err) } @@ -118,6 +117,16 @@ func (m *Manager) UpdateDomains(entries []*ForwarderEntry) { m.dnsForwarder.UpdateDomains(entries) } +// Domains returns the entries currently being served, or nil when the +// forwarder is not running. +func (m *Manager) Domains() []*ForwarderEntry { + if m.dnsForwarder == nil { + return nil + } + + return m.dnsForwarder.Domains() +} + func (m *Manager) Stop(ctx context.Context) error { if m.dnsForwarder == nil { return nil @@ -160,12 +169,13 @@ func (m *Manager) allowDNSFirewall() error { return nil } - dnsRules, err := m.firewall.AddPeerFiltering(nil, net.IP{0, 0, 0, 0}, firewall.ProtocolUDP, nil, dport, firewall.ActionAccept, "") + anyV4 := []netip.Prefix{netip.PrefixFrom(netip.IPv4Unspecified(), 0)} + dnsRule, err := m.firewall.AddFilterRule(nil, anyV4, firewall.Network{}, firewall.ProtocolUDP, nil, dport, firewall.ActionAccept) if err != nil { return fmt.Errorf("add udp firewall rule: %w", err) } - tcpRules, err := m.firewall.AddPeerFiltering(nil, net.IP{0, 0, 0, 0}, firewall.ProtocolTCP, nil, dport, firewall.ActionAccept, "") + tcpRule, err := m.firewall.AddFilterRule(nil, anyV4, firewall.Network{}, firewall.ProtocolTCP, nil, dport, firewall.ActionAccept) if err != nil { return fmt.Errorf("add tcp firewall rule: %w", err) } @@ -174,8 +184,12 @@ func (m *Manager) allowDNSFirewall() error { return fmt.Errorf("flush: %w", err) } - m.fwRules = dnsRules - m.tcpRules = tcpRules + if dnsRule != nil { + m.fwRules = []firewall.Rule{dnsRule} + } + if tcpRule != nil { + m.tcpRules = []firewall.Rule{tcpRule} + } m.registerNetstackServices() @@ -209,12 +223,12 @@ func (m *Manager) unregisterNetstackServices() { func (m *Manager) dropDNSFirewall() error { var mErr *multierror.Error for _, rule := range m.fwRules { - if err := m.firewall.DeletePeerRule(rule); err != nil { + if err := m.firewall.DeleteFilterRule(rule); err != nil { mErr = multierror.Append(mErr, fmt.Errorf("failed to delete DNS router rules, err: %v", err)) } } for _, rule := range m.tcpRules { - if err := m.firewall.DeletePeerRule(rule); err != nil { + if err := m.firewall.DeleteFilterRule(rule); err != nil { mErr = multierror.Append(mErr, fmt.Errorf("failed to delete DNS router rules, err: %v", err)) } } diff --git a/client/internal/ebpf/ebpf/bpf_bpfeb.go b/client/internal/ebpf/ebpf/bpf_bpfeb.go index 04b19883b..4b6230217 100644 --- a/client/internal/ebpf/ebpf/bpf_bpfeb.go +++ b/client/internal/ebpf/ebpf/bpf_bpfeb.go @@ -1,5 +1,5 @@ // Code generated by bpf2go; DO NOT EDIT. -//go:build arm64be || armbe || mips || mips64 || mips64p32 || ppc64 || s390 || s390x || sparc || sparc64 +//go:build mips || mips64 || ppc64 || s390x package ebpf @@ -47,9 +47,10 @@ func loadBpfObjects(obj interface{}, opts *ebpf.CollectionOptions) error { type bpfSpecs struct { bpfProgramSpecs bpfMapSpecs + bpfVariableSpecs } -// bpfSpecs contains programs before they are loaded into the kernel. +// bpfProgramSpecs contains programs before they are loaded into the kernel. // // It can be passed ebpf.CollectionSpec.Assign. type bpfProgramSpecs struct { @@ -61,17 +62,28 @@ type bpfProgramSpecs struct { // It can be passed ebpf.CollectionSpec.Assign. type bpfMapSpecs struct { NbFeatures *ebpf.MapSpec `ebpf:"nb_features"` - NbMapDnsIp *ebpf.MapSpec `ebpf:"nb_map_dns_ip"` - NbMapDnsPort *ebpf.MapSpec `ebpf:"nb_map_dns_port"` NbWgProxySettingsMap *ebpf.MapSpec `ebpf:"nb_wg_proxy_settings_map"` } +// bpfVariableSpecs contains global variables before they are loaded into the kernel. +// +// It can be passed ebpf.CollectionSpec.Assign. +type bpfVariableSpecs struct { + FlagFeatureWgProxy *ebpf.VariableSpec `ebpf:"flag_feature_wg_proxy"` + MapKeyFeatures *ebpf.VariableSpec `ebpf:"map_key_features"` + MapKeyProxyPort *ebpf.VariableSpec `ebpf:"map_key_proxy_port"` + MapKeyWgPort *ebpf.VariableSpec `ebpf:"map_key_wg_port"` + ProxyPort *ebpf.VariableSpec `ebpf:"proxy_port"` + WgPort *ebpf.VariableSpec `ebpf:"wg_port"` +} + // bpfObjects contains all objects after they have been loaded into the kernel. // // It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. type bpfObjects struct { bpfPrograms bpfMaps + bpfVariables } func (o *bpfObjects) Close() error { @@ -86,20 +98,28 @@ func (o *bpfObjects) Close() error { // It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. type bpfMaps struct { NbFeatures *ebpf.Map `ebpf:"nb_features"` - NbMapDnsIp *ebpf.Map `ebpf:"nb_map_dns_ip"` - NbMapDnsPort *ebpf.Map `ebpf:"nb_map_dns_port"` NbWgProxySettingsMap *ebpf.Map `ebpf:"nb_wg_proxy_settings_map"` } func (m *bpfMaps) Close() error { return _BpfClose( m.NbFeatures, - m.NbMapDnsIp, - m.NbMapDnsPort, m.NbWgProxySettingsMap, ) } +// bpfVariables contains all global variables after they have been loaded into the kernel. +// +// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. +type bpfVariables struct { + FlagFeatureWgProxy *ebpf.Variable `ebpf:"flag_feature_wg_proxy"` + MapKeyFeatures *ebpf.Variable `ebpf:"map_key_features"` + MapKeyProxyPort *ebpf.Variable `ebpf:"map_key_proxy_port"` + MapKeyWgPort *ebpf.Variable `ebpf:"map_key_wg_port"` + ProxyPort *ebpf.Variable `ebpf:"proxy_port"` + WgPort *ebpf.Variable `ebpf:"wg_port"` +} + // bpfPrograms contains all programs after they have been loaded into the kernel. // // It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. diff --git a/client/internal/ebpf/ebpf/bpf_bpfeb.o b/client/internal/ebpf/ebpf/bpf_bpfeb.o index 6e9cda44a..b435d4964 100644 Binary files a/client/internal/ebpf/ebpf/bpf_bpfeb.o and b/client/internal/ebpf/ebpf/bpf_bpfeb.o differ diff --git a/client/internal/ebpf/ebpf/bpf_bpfel.go b/client/internal/ebpf/ebpf/bpf_bpfel.go index 03b494aa2..f56efc901 100644 --- a/client/internal/ebpf/ebpf/bpf_bpfel.go +++ b/client/internal/ebpf/ebpf/bpf_bpfel.go @@ -1,5 +1,5 @@ // Code generated by bpf2go; DO NOT EDIT. -//go:build 386 || amd64 || amd64p32 || arm || arm64 || loong64 || mips64le || mips64p32le || mipsle || ppc64le || riscv64 +//go:build 386 || amd64 || arm || arm64 || loong64 || mips64le || mipsle || ppc64le || riscv64 || wasm package ebpf @@ -47,9 +47,10 @@ func loadBpfObjects(obj interface{}, opts *ebpf.CollectionOptions) error { type bpfSpecs struct { bpfProgramSpecs bpfMapSpecs + bpfVariableSpecs } -// bpfSpecs contains programs before they are loaded into the kernel. +// bpfProgramSpecs contains programs before they are loaded into the kernel. // // It can be passed ebpf.CollectionSpec.Assign. type bpfProgramSpecs struct { @@ -61,17 +62,28 @@ type bpfProgramSpecs struct { // It can be passed ebpf.CollectionSpec.Assign. type bpfMapSpecs struct { NbFeatures *ebpf.MapSpec `ebpf:"nb_features"` - NbMapDnsIp *ebpf.MapSpec `ebpf:"nb_map_dns_ip"` - NbMapDnsPort *ebpf.MapSpec `ebpf:"nb_map_dns_port"` NbWgProxySettingsMap *ebpf.MapSpec `ebpf:"nb_wg_proxy_settings_map"` } +// bpfVariableSpecs contains global variables before they are loaded into the kernel. +// +// It can be passed ebpf.CollectionSpec.Assign. +type bpfVariableSpecs struct { + FlagFeatureWgProxy *ebpf.VariableSpec `ebpf:"flag_feature_wg_proxy"` + MapKeyFeatures *ebpf.VariableSpec `ebpf:"map_key_features"` + MapKeyProxyPort *ebpf.VariableSpec `ebpf:"map_key_proxy_port"` + MapKeyWgPort *ebpf.VariableSpec `ebpf:"map_key_wg_port"` + ProxyPort *ebpf.VariableSpec `ebpf:"proxy_port"` + WgPort *ebpf.VariableSpec `ebpf:"wg_port"` +} + // bpfObjects contains all objects after they have been loaded into the kernel. // // It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. type bpfObjects struct { bpfPrograms bpfMaps + bpfVariables } func (o *bpfObjects) Close() error { @@ -86,20 +98,28 @@ func (o *bpfObjects) Close() error { // It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. type bpfMaps struct { NbFeatures *ebpf.Map `ebpf:"nb_features"` - NbMapDnsIp *ebpf.Map `ebpf:"nb_map_dns_ip"` - NbMapDnsPort *ebpf.Map `ebpf:"nb_map_dns_port"` NbWgProxySettingsMap *ebpf.Map `ebpf:"nb_wg_proxy_settings_map"` } func (m *bpfMaps) Close() error { return _BpfClose( m.NbFeatures, - m.NbMapDnsIp, - m.NbMapDnsPort, m.NbWgProxySettingsMap, ) } +// bpfVariables contains all global variables after they have been loaded into the kernel. +// +// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. +type bpfVariables struct { + FlagFeatureWgProxy *ebpf.Variable `ebpf:"flag_feature_wg_proxy"` + MapKeyFeatures *ebpf.Variable `ebpf:"map_key_features"` + MapKeyProxyPort *ebpf.Variable `ebpf:"map_key_proxy_port"` + MapKeyWgPort *ebpf.Variable `ebpf:"map_key_wg_port"` + ProxyPort *ebpf.Variable `ebpf:"proxy_port"` + WgPort *ebpf.Variable `ebpf:"wg_port"` +} + // bpfPrograms contains all programs after they have been loaded into the kernel. // // It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. diff --git a/client/internal/ebpf/ebpf/bpf_bpfel.o b/client/internal/ebpf/ebpf/bpf_bpfel.o index 6338f4774..a388b6d6d 100644 Binary files a/client/internal/ebpf/ebpf/bpf_bpfel.o and b/client/internal/ebpf/ebpf/bpf_bpfel.o differ diff --git a/client/internal/ebpf/ebpf/dns_fwd_linux.go b/client/internal/ebpf/ebpf/dns_fwd_linux.go deleted file mode 100644 index 1e7774573..000000000 --- a/client/internal/ebpf/ebpf/dns_fwd_linux.go +++ /dev/null @@ -1,52 +0,0 @@ -package ebpf - -import ( - "encoding/binary" - "fmt" - "net/netip" - - log "github.com/sirupsen/logrus" -) - -const ( - mapKeyDNSIP uint32 = 0 - mapKeyDNSPort uint32 = 1 -) - -func (tf *GeneralManager) LoadDNSFwd(ip netip.Addr, dnsPort int) error { - log.Debugf("load eBPF DNS forwarder, watching addr: %s:53, redirect to port: %d", ip, dnsPort) - tf.lock.Lock() - defer tf.lock.Unlock() - - err := tf.loadXdp() - if err != nil { - return err - } - - if !ip.Is4() { - return fmt.Errorf("eBPF DNS forwarder only supports IPv4, got %s", ip) - } - ip4 := ip.As4() - err = tf.bpfObjs.NbMapDnsIp.Put(mapKeyDNSIP, binary.BigEndian.Uint32(ip4[:])) - if err != nil { - return err - } - - err = tf.bpfObjs.NbMapDnsPort.Put(mapKeyDNSPort, uint16(dnsPort)) - if err != nil { - return err - } - - tf.setFeatureFlag(featureFlagDnsForwarder) - err = tf.bpfObjs.NbFeatures.Put(mapKeyFeatures, tf.featureFlags) - if err != nil { - return err - } - return nil -} - -func (tf *GeneralManager) FreeDNSFwd() error { - log.Debugf("free ebpf DNS forwarder") - return tf.unsetFeatureFlag(featureFlagDnsForwarder) -} - diff --git a/client/internal/ebpf/ebpf/manager_linux.go b/client/internal/ebpf/ebpf/manager_linux.go index 7520a6387..a13f5f19a 100644 --- a/client/internal/ebpf/ebpf/manager_linux.go +++ b/client/internal/ebpf/ebpf/manager_linux.go @@ -15,8 +15,7 @@ import ( const ( mapKeyFeatures uint32 = 0 - featureFlagWGProxy = 0b00000001 - featureFlagDnsForwarder = 0b00000010 + featureFlagWGProxy = 0b00000001 ) var ( @@ -28,9 +27,9 @@ var ( // GeneralManager is used to load multiple eBPF programs with a custom check (if then) done in prog.c // The manager simply adds a feature (byte) of each program to a map that is shared between the userspace and kernel. -// When packet arrives, the C code checks for each feature (if it is set) and executes each enabled program (e.g., dns_fwd.c and wg_proxy.c). +// When packet arrives, the C code checks for each feature (if it is set) and executes each enabled program (e.g., wg_proxy.c). // -//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang-14 bpf src/prog.c -- -I /usr/x86_64-linux-gnu/include +//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang-14 bpf src/prog.c -- -I /usr/x86_64-linux-gnu/include -include src/bpf_map_def.h type GeneralManager struct { lock sync.Mutex link link.Link diff --git a/client/internal/ebpf/ebpf/manager_linux_test.go b/client/internal/ebpf/ebpf/manager_linux_test.go index 5664a4565..e09fcb977 100644 --- a/client/internal/ebpf/ebpf/manager_linux_test.go +++ b/client/internal/ebpf/ebpf/manager_linux_test.go @@ -7,33 +7,24 @@ import ( func TestManager_setFeatureFlag(t *testing.T) { mgr := GeneralManager{} mgr.setFeatureFlag(featureFlagWGProxy) - if mgr.featureFlags != 1 { + if mgr.featureFlags != featureFlagWGProxy { t.Errorf("invalid feature state") } - mgr.setFeatureFlag(featureFlagDnsForwarder) - if mgr.featureFlags != 3 { - t.Errorf("invalid feature state") + mgr.setFeatureFlag(featureFlagWGProxy) + if mgr.featureFlags != featureFlagWGProxy { + t.Errorf("setting a flag twice must be idempotent, got: %d", mgr.featureFlags) } } func TestManager_unsetFeatureFlag(t *testing.T) { mgr := GeneralManager{} mgr.setFeatureFlag(featureFlagWGProxy) - mgr.setFeatureFlag(featureFlagDnsForwarder) err := mgr.unsetFeatureFlag(featureFlagWGProxy) if err != nil { t.Errorf("unexpected error: %s", err) } - if mgr.featureFlags != 2 { - t.Errorf("invalid feature state, expected: %d, got: %d", 2, mgr.featureFlags) - } - - err = mgr.unsetFeatureFlag(featureFlagDnsForwarder) - if err != nil { - t.Errorf("unexpected error: %s", err) - } if mgr.featureFlags != 0 { t.Errorf("invalid feature state, expected: %d, got: %d", 0, mgr.featureFlags) } diff --git a/client/internal/ebpf/ebpf/src/bpf_map_def.h b/client/internal/ebpf/ebpf/src/bpf_map_def.h new file mode 100644 index 000000000..9528fb592 --- /dev/null +++ b/client/internal/ebpf/ebpf/src/bpf_map_def.h @@ -0,0 +1,16 @@ +// libbpf 1.0 removed struct bpf_map_def, but the programs here keep the legacy +// map definitions: they load on kernels built without BTF, which BTF-style +// (SEC(".maps")) definitions do not. Define the struct ourselves so the +// programs compile against current libbpf headers. +#ifndef NB_BPF_MAP_DEF_H +#define NB_BPF_MAP_DEF_H + +struct bpf_map_def { + unsigned int type; + unsigned int key_size; + unsigned int value_size; + unsigned int max_entries; + unsigned int map_flags; +}; + +#endif diff --git a/client/internal/ebpf/ebpf/src/dns_fwd.c b/client/internal/ebpf/ebpf/src/dns_fwd.c deleted file mode 100644 index 5f3fbcc32..000000000 --- a/client/internal/ebpf/ebpf/src/dns_fwd.c +++ /dev/null @@ -1,64 +0,0 @@ -const __u32 map_key_dns_ip = 0; -const __u32 map_key_dns_port = 1; - -struct bpf_map_def SEC("maps") nb_map_dns_ip = { - .type = BPF_MAP_TYPE_ARRAY, - .key_size = sizeof(__u32), - .value_size = sizeof(__u32), - .max_entries = 10, -}; - -struct bpf_map_def SEC("maps") nb_map_dns_port = { - .type = BPF_MAP_TYPE_ARRAY, - .key_size = sizeof(__u32), - .value_size = sizeof(__u16), - .max_entries = 10, -}; - -__be32 dns_ip = 0; -__be16 dns_port = 0; - -// 13568 is 53 in big endian -__be16 GENERAL_DNS_PORT = 13568; - -bool read_settings() { - __u16 *port_value; - __u32 *ip_value; - - // read dns ip - ip_value = bpf_map_lookup_elem(&nb_map_dns_ip, &map_key_dns_ip); - if(!ip_value) { - return false; - } - dns_ip = htonl(*ip_value); - - // read dns port - port_value = bpf_map_lookup_elem(&nb_map_dns_port, &map_key_dns_port); - if (!port_value) { - return false; - } - dns_port = htons(*port_value); - return true; -} - -int xdp_dns_fwd(struct iphdr *ip, struct udphdr *udp) { - if (dns_port == 0) { - if(!read_settings()){ - return XDP_PASS; - } - // bpf_printk("dns port: %d", ntohs(dns_port)); - // bpf_printk("dns ip: %d", ntohl(dns_ip)); - } - - if (udp->dest == GENERAL_DNS_PORT && ip->daddr == dns_ip) { - udp->dest = dns_port; - return XDP_PASS; - } - - if (udp->source == dns_port && ip->saddr == dns_ip) { - udp->source = GENERAL_DNS_PORT; - return XDP_PASS; - } - - return XDP_PASS; -} diff --git a/client/internal/ebpf/ebpf/src/prog.c b/client/internal/ebpf/ebpf/src/prog.c index f32103f28..44ee53458 100644 --- a/client/internal/ebpf/ebpf/src/prog.c +++ b/client/internal/ebpf/ebpf/src/prog.c @@ -5,11 +5,9 @@ #include #include #include -#include "dns_fwd.c" #include "wg_proxy.c" const __u16 flag_feature_wg_proxy = 0b01; -const __u16 flag_feature_dns_fwd = 0b10; const __u32 map_key_features = 0; struct bpf_map_def SEC("maps") nb_features = { @@ -48,10 +46,6 @@ int nb_xdp_prog(struct xdp_md *ctx) { return XDP_PASS; } - if (*features & flag_feature_dns_fwd) { - xdp_dns_fwd(ip, udp); - } - if (*features & flag_feature_wg_proxy) { xdp_wg_proxy(ip, udp); } diff --git a/client/internal/ebpf/ebpf/src/readme.md b/client/internal/ebpf/ebpf/src/readme.md index 0ab393dd4..aa47847da 100644 --- a/client/internal/ebpf/ebpf/src/readme.md +++ b/client/internal/ebpf/ebpf/src/readme.md @@ -1,8 +1,18 @@ -# DNS forwarder +# XDP programs -The agent attach the XDP program to the lo device. We can not use fake address in eBPF because the -traffic does not appear in the eBPF program. The program capture the traffic on wg_ip:53 and -overwrite in it the destination port to 5053. +`prog.c` is attached to the `lo` device and dispatches to the features enabled in the +`nb_features` map. The only feature is the WireGuard proxy (`wg_proxy.c`): it rewrites +loopback UDP sent from the WireGuard listen port so it reaches the userspace relay proxy +port instead, and swaps the peer endpoint port into the source so the proxy can tell +peers apart. + +Maps use the legacy `struct bpf_map_def` form, defined in `bpf_map_def.h` because libbpf +1.0 removed it. They load on kernels built without BTF, which BTF-style (`SEC(".maps")`) +definitions do not. + +Regenerate the objects with `go generate ./client/internal/ebpf/ebpf/`; it needs +`clang-14`. Loading a regenerated object needs root, attaching it needs `bpf_link` +(kernel >= 5.7), and only one XDP program can own `lo` at a time. # Debug diff --git a/client/internal/ebpf/ebpf/src/wg_proxy.c b/client/internal/ebpf/ebpf/src/wg_proxy.c index 88fea65cf..5e7474928 100644 --- a/client/internal/ebpf/ebpf/src/wg_proxy.c +++ b/client/internal/ebpf/ebpf/src/wg_proxy.c @@ -50,5 +50,11 @@ int xdp_wg_proxy(struct iphdr *ip, struct udphdr *udp) { __be16 new_dst_port = htons(proxy_port); udp->dest = new_dst_port; udp->source = new_src_port; + + // The ports are covered by the UDP checksum. This is an IPv4 loopback hop + // and the payload is already integrity-protected, so clear the checksum (a + // zero UDP checksum means "not computed" for IPv4) rather than leave a + // stale value the kernel would drop as UDP_CSUM. + udp->check = 0; return XDP_PASS; } diff --git a/client/internal/ebpf/manager/manager.go b/client/internal/ebpf/manager/manager.go index 25a767090..fdc5d8d82 100644 --- a/client/internal/ebpf/manager/manager.go +++ b/client/internal/ebpf/manager/manager.go @@ -1,11 +1,7 @@ package manager -import "net/netip" - -// Manager is used to load multiple eBPF programs. E.g., current DNS programs and WireGuard proxy +// Manager is used to load multiple eBPF programs. E.g., the WireGuard proxy type Manager interface { - LoadDNSFwd(ip netip.Addr, dnsPort int) error - FreeDNSFwd() error LoadWgProxy(proxyPort, wgPort int) error FreeWGProxy() error } diff --git a/client/internal/elevate/elevate.go b/client/internal/elevate/elevate.go new file mode 100644 index 000000000..aa5a5da78 --- /dev/null +++ b/client/internal/elevate/elevate.go @@ -0,0 +1,74 @@ +// Package elevate re-runs this very executable under the operating system's own +// privilege-elevation mechanism and waits for it to finish. +// +// It exists so that a change the daemon restricts to root/administrator can be +// authorized from the GUI, by the user, at the moment they ask for it: Windows +// shows the UAC consent dialog, macOS the system authentication dialog, and +// Linux/FreeBSD the session's polkit agent. The credentials, where any are +// asked for, are collected by the operating system and never pass through +// NetBird. +// +// What the elevated process then does is the caller's business: it is the same +// binary, in a one-shot mode, and it is authorized by the daemon exactly like +// any other privileged caller, from the identity the kernel reports on the +// control channel. Nothing here grants privilege, and the daemon gains no new +// way to be talked into something: elevation only changes who is calling it. +package elevate + +import ( + "context" + "errors" + + log "github.com/sirupsen/logrus" +) + +// AppliedMarker is what the elevated process prints on standard output once it has +// done what it was run for. +// +// macOS's AuthorizationExecuteWithPrivileges reports no exit status and does not +// say which process it started, so there this line is the only evidence that the +// change was applied. The other platforms have an exit code and ignore it. +const AppliedMarker = "netbird-elevated: applied" + +var ( + // ErrDeclined reports that the user dismissed the prompt or did not + // authenticate. Nothing happened and nothing is wrong: a caller undoes its + // optimistic update and stays quiet. + ErrDeclined = errors.New("authorization declined") + + // ErrUnavailable reports that this host has no elevation mechanism we can + // drive: no polkit on a Unix desktop, or an executable we decline to run as + // root. A caller falls back to telling the user which command to run. + ErrUnavailable = errors.New("no privilege elevation mechanism available") +) + +// Run runs this executable with args under the platform's elevation mechanism +// and waits for it to exit. A non-zero exit is returned as an error, so the +// caller can treat a completed Run as the operation having succeeded. +// +// The args are the caller's own command line, so they cross no privilege +// boundary: only a user who has just authenticated as an administrator can get +// them run at all. +func Run(ctx context.Context, args ...string) error { + self, err := trustedSelf() + if err != nil { + return err + } + return run(ctx, self, args) +} + +// Available reports whether Run has a mechanism to use on this host, so a caller +// can offer the prompt only when there is one and otherwise fall back to +// guidance the user can act on. It answers from what is installed, not from what +// the user is allowed to do: an administrator's password may still be required +// and may still not be given, which is ErrDeclined from Run. +func Available() bool { + if _, err := trustedSelf(); err != nil { + // Worth a line: this is also what a build run from a group-writable + // directory hits, and there is nothing in the UI to say why the offer is + // missing. + log.Debugf("not offering privilege elevation: %v", err) + return false + } + return mechanismAvailable() +} diff --git a/client/internal/elevate/output.go b/client/internal/elevate/output.go new file mode 100644 index 000000000..6e1646bd3 --- /dev/null +++ b/client/internal/elevate/output.go @@ -0,0 +1,18 @@ +package elevate + +import "strings" + +// noOutput stands in for a process that said nothing, so that a report of what it +// said still reads as a sentence. +const noOutput = "no output" + +func firstLine(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return noOutput + } + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s +} diff --git a/client/internal/elevate/output_test.go b/client/internal/elevate/output_test.go new file mode 100644 index 000000000..3faacf53a --- /dev/null +++ b/client/internal/elevate/output_test.go @@ -0,0 +1,21 @@ +package elevate + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFirstLine(t *testing.T) { + tests := []struct{ in, want string }{ + {in: "", want: noOutput}, + {in: " \n ", want: noOutput}, + {in: "one line", want: "one line"}, + {in: "first\nsecond", want: "first"}, + {in: "\nsecond\n", want: "second"}, + } + + for _, tt := range tests { + assert.Equal(t, tt.want, firstLine(tt.in), "input %q", tt.in) + } +} diff --git a/client/internal/elevate/run_darwin.go b/client/internal/elevate/run_darwin.go new file mode 100644 index 000000000..6b0e4fc0d --- /dev/null +++ b/client/internal/elevate/run_darwin.go @@ -0,0 +1,359 @@ +package elevate + +import ( + "context" + "errors" + "fmt" + "os" + "runtime" + "strings" + "sync" + "syscall" + "unsafe" + + "github.com/ebitengine/purego" + log "github.com/sirupsen/logrus" +) + +// Authorization Services, reached through purego rather than cgo so the released +// binaries keep building with CGO_ENABLED=0. +// +// The prompt belongs to this process, which is what makes it carry the +// application's name and our own explanation. Going through osascript instead puts +// the very same trampoline behind a dialog attributed to osascript, and means +// handing a shell a command line to re-parse. +// +// # On AuthorizationExecuteWithPrivileges +// +// It is deprecated, and Apple's guidance (Quinn, "BSD Privilege Escalation on +// macOS", developer.apple.com/forums/thread/708765) is "while it still works, it's +// been deprecated for many years. Do not use it in a widely distributed product." +// It is used here anyway, knowingly, because the alternatives Apple offers are for +// *obtaining* ongoing privileges — an installer package, SMAppService, SMJobBless — +// and NetBird already has what they would install: a launchd daemon running as +// root. What is missing is only a way for an unprivileged client to ask it to act. +// +// The way to that without a deprecated call is to authorize the client instead of +// elevating one: the app takes the right with AuthorizationCreate, passes the +// AuthorizationExternalForm to the daemon, and the daemon checks it with +// AuthorizationCopyRights before acting — none of which is deprecated. It is the +// better design and it is where this should end up. It also means the daemon +// accepting an authorization over its control socket, which is a new way to be +// asked for privileged work and wants reviewing as such, so it is deliberately not +// bundled in with the rest of this. +// +// Until then, three things keep the deprecation from being a trap. Every symbol is +// resolved with an error rather than a panic, so a macOS that has dropped this +// function leaves the app offering the user a command instead of crashing on the +// way to a prompt. A failure to run the tool is reported as ErrUnavailable, so the +// fallback is the same one an agent-less Linux session gets. And the whole path +// runs under guard, which turns a panic out of the FFI layer into that same +// fallback. +// +// The trampoline passes on the environment it was given, so what it starts as root +// must be an executable this user's peers cannot influence: that is what +// trustedSelf refuses, and what signing the binary settles for the loader. + +const ( + securityFramework = "/System/Library/Frameworks/Security.framework/Security" + libSystem = "/usr/lib/libSystem.B.dylib" + + // trampoline is what the framework hands the tool to. Present on every macOS, + // and worth confirming before offering a prompt rather than mid-prompt. + trampoline = "/usr/libexec/security_authtrampoline" +) + +// rightExecute is the right an administrator holds, and what +// AuthorizationExecuteWithPrivileges requires of us. +const rightExecute = "system.privilege.admin" + +// promptKey is kAuthorizationEnvironmentPrompt, which puts a sentence of ours above +// the system's in the dialog. It is about the change rather than the mechanism. +const ( + promptKey = "prompt" + promptText = "NetBird needs to change a setting that grants SSH access to this computer." +) + +// OSStatus values from SecBase.h that mean something to us; anything else is +// reported as it comes. +const ( + errAuthorizationSuccess = 0 + errAuthorizationDenied = -60005 + errAuthorizationCanceled = -60006 + errAuthorizationInteractionNotAllowed = -60007 + errAuthorizationToolExecuteFailure = -60031 + errAuthorizationToolEnvironmentError = -60032 +) + +// AuthorizationFlags from Authorization.h. +const ( + flagDefaults = 0 + flagInteractionAllowed = 1 << 0 + flagExtendRights = 1 << 1 + flagDestroyRights = 1 << 3 + flagPreAuthorize = 1 << 4 +) + +// authorizationItem mirrors AuthorizationItem: a name, and a value the name gives +// meaning to. 32 bytes on both amd64 and arm64. +type authorizationItem struct { + name *byte + valueLength uintptr + value unsafe.Pointer + // flags is reserved by the API and always zero. Declared because the layout + // is the contract: without it the struct is 24 bytes where C reads 32. + flags uint32 //nolint:unused // part of the C layout +} + +// authorizationItemSet mirrors AuthorizationItemSet, which serves as both an +// AuthorizationRights and an AuthorizationEnvironment. +type authorizationItemSet struct { + count uint32 + items *authorizationItem +} + +var ( + authorizationCreate func(rights, environment *authorizationItemSet, flags uint32, authorization *uintptr) int32 + authorizationExecuteWithPrivileges func(authorization uintptr, pathToTool string, options uint32, arguments *uintptr, communicationsPipe *uintptr) int32 + authorizationFree func(authorization uintptr, flags uint32) int32 + fileno func(stream uintptr) int32 + fclose func(stream uintptr) int32 + + loadOnce sync.Once + loadErr error +) + +// load resolves the functions once. A framework that cannot be opened, or a symbol +// that is no longer there, leaves the host without a mechanism rather than taking +// the process down with it: see the note on deprecation above. +func load() error { + loadOnce.Do(func() { loadErr = guard("loading Security.framework", resolve) }) + return loadErr +} + +// guard turns a panic out of the FFI layer into an error, so an API that has +// changed under us costs the user a prompt rather than the window they were +// clicking in. purego panics on a signature it cannot map, and this is the one +// place in the client that calls a deprecated system function. +// +// It catches Go panics, which is what purego raises. A fault inside the framework +// itself is not a panic and not recoverable; the layout the tests pin down is what +// stands between us and that. +func guard(what string, fn func() error) (err error) { + defer func() { + r := recover() + if r == nil { + return + } + log.Errorf("%s panicked: %v", what, r) + err = fmt.Errorf("%w: %s: %v", ErrUnavailable, what, r) + }() + return fn() +} + +func resolve() error { + security, err := purego.Dlopen(securityFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL) + if err != nil { + return fmt.Errorf("open %s: %w", securityFramework, err) + } + system, err := purego.Dlopen(libSystem, purego.RTLD_LAZY|purego.RTLD_GLOBAL) + if err != nil { + return fmt.Errorf("open %s: %w", libSystem, err) + } + + // purego.RegisterLibFunc panics on a symbol it cannot find, which is not how a + // deprecated function's disappearance should reach the user. + for _, fn := range []struct { + ptr any + handle uintptr + name string + }{ + {&authorizationCreate, security, "AuthorizationCreate"}, + {&authorizationExecuteWithPrivileges, security, "AuthorizationExecuteWithPrivileges"}, + {&authorizationFree, security, "AuthorizationFree"}, + {&fileno, system, "fileno"}, + {&fclose, system, "fclose"}, + } { + symbol, err := purego.Dlsym(fn.handle, fn.name) + if err != nil { + return fmt.Errorf("resolve %s: %w", fn.name, err) + } + if symbol == 0 { + return fmt.Errorf("resolve %s: not present on this system", fn.name) + } + purego.RegisterFunc(fn.ptr, symbol) + } + return nil +} + +// run asks the system to run self as root: first for the right, which is what puts +// up the authentication dialog and collects the password or takes the Touch ID, +// then for the tool. The credentials go to the system's authorization trampoline +// and never to us. +// +// The context bounds only our own waiting; the dialog belongs to the system and +// closes when the user answers it. +func run(ctx context.Context, self string, args []string) error { + if err := load(); err != nil { + return fmt.Errorf("%w: %v", ErrUnavailable, err) + } + + return guard("asking for privileges", func() error { + authorization, err := authorize() + if err != nil { + return err + } + defer authorizationFree(authorization, flagDestroyRights) + + return execute(ctx, authorization, self, args) + }) +} + +func mechanismAvailable() bool { + if err := load(); err != nil { + return false + } + info, err := os.Stat(trampoline) + return err == nil && !info.IsDir() +} + +// authorize obtains the right, prompting for it. A dismissed dialog comes back as +// errAuthorizationCanceled and a password given up on as errAuthorizationDenied; +// both are the user's answer rather than a failure. +func authorize() (uintptr, error) { + var pinner runtime.Pinner + defer pinner.Unpin() + + rights := itemSet(&pinner, authorizationItem{name: cString(&pinner, rightExecute)}) + environment := itemSet(&pinner, promptItem(&pinner)) + + var authorization uintptr + status := authorizationCreate(rights, environment, + flagDefaults|flagInteractionAllowed|flagPreAuthorize|flagExtendRights, &authorization) + + switch status { + case errAuthorizationSuccess: + return authorization, nil + case errAuthorizationCanceled, errAuthorizationDenied: + return 0, ErrDeclined + case errAuthorizationInteractionNotAllowed: + // Nowhere to put a dialog, so there is nobody to ask: a launch daemon, or + // a session with no window server. + return 0, fmt.Errorf("%w: this session cannot show an authorization prompt", ErrUnavailable) + default: + return 0, fmt.Errorf("request %s: OSStatus %d", rightExecute, status) + } +} + +// execute runs the tool with the right in hand and waits for it by reading the pipe +// it is given until the tool closes it. +// +// AuthorizationExecuteWithPrivileges reports no exit status and does not say what +// process it started, which is why the one-shot says so itself: what it prints is +// the only evidence that the change was applied. +func execute(ctx context.Context, authorization uintptr, self string, args []string) error { + var pinner runtime.Pinner + defer pinner.Unpin() + + argv := make([]uintptr, 0, len(args)+1) + for _, arg := range args { + argv = append(argv, uintptr(unsafe.Pointer(cString(&pinner, arg)))) + } + argv = append(argv, 0) + pinner.Pin(&argv[0]) + + var pipe uintptr + status := authorizationExecuteWithPrivileges(authorization, self, flagDefaults, &argv[0], &pipe) + switch status { + case errAuthorizationSuccess: + case errAuthorizationCanceled: + return ErrDeclined + case errAuthorizationToolExecuteFailure, errAuthorizationToolEnvironmentError: + // The right was granted and the tool still did not start. Nothing the user + // can do about it from here, so point them at the command instead. + return fmt.Errorf("%w: the system would not run %s elevated (OSStatus %d)", ErrUnavailable, self, status) + default: + return fmt.Errorf("run %s elevated: OSStatus %d", self, status) + } + + out, err := readPipe(ctx, pipe) + if err != nil { + return err + } + return checkApplied(out) +} + +// checkApplied reads the one-shot's report, which stands in for the exit status +// there is no way to ask for here. A run that said nothing did not apply the +// change, whatever else went on. +func checkApplied(out string) error { + if !strings.Contains(out, AppliedMarker) { + return fmt.Errorf("elevated netbird did not report the change as applied: %s", firstLine(out)) + } + return nil +} + +// readPipe drains the tool's output, which ends when the tool exits and is +// therefore also how we wait for it. +func readPipe(ctx context.Context, pipe uintptr) (string, error) { + if pipe == 0 { + return "", nil + } + defer fclose(pipe) + + fd := int(fileno(pipe)) + if fd < 0 { + return "", nil + } + + var out strings.Builder + buf := make([]byte, 4096) + for { + if err := ctx.Err(); err != nil { + return out.String(), err + } + n, err := syscall.Read(fd, buf) + if n > 0 { + out.Write(buf[:n]) + } + switch { + case errors.Is(err, syscall.EINTR): + // A signal landed mid-read, which says nothing about the tool. + continue + case err != nil: + log.Debugf("read the elevated process's output: %v", err) + return out.String(), nil + case n <= 0: + // End of file: the tool closed the pipe, which is how it exiting + // reaches us. + return out.String(), nil + } + } +} + +// itemSet builds an AuthorizationItemSet over items, pinned for the call. +func itemSet(pinner *runtime.Pinner, items ...authorizationItem) *authorizationItemSet { + pinner.Pin(&items[0]) + set := &authorizationItemSet{count: uint32(len(items)), items: &items[0]} + pinner.Pin(set) + return set +} + +// promptItem is the environment entry carrying our sentence for the dialog. +func promptItem(pinner *runtime.Pinner) authorizationItem { + value := []byte(promptText) + pinner.Pin(&value[0]) + return authorizationItem{ + name: cString(pinner, promptKey), + valueLength: uintptr(len(value)), + value: unsafe.Pointer(&value[0]), + } +} + +// cString returns a NUL-terminated copy of s, pinned so the C side may hold it for +// the duration of the call. +func cString(pinner *runtime.Pinner, s string) *byte { + b := append([]byte(s), 0) + pinner.Pin(&b[0]) + return &b[0] +} diff --git a/client/internal/elevate/run_darwin_test.go b/client/internal/elevate/run_darwin_test.go new file mode 100644 index 000000000..f6c58c8cb --- /dev/null +++ b/client/internal/elevate/run_darwin_test.go @@ -0,0 +1,111 @@ +package elevate + +import ( + "errors" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The framework has to load and the symbols have to resolve, or nothing else here +// means anything. +func TestSecurityFrameworkLoads(t *testing.T) { + require.NoError(t, load(), "Security.framework must open") + + for name, fn := range map[string]any{ + "AuthorizationCreate": authorizationCreate, + "AuthorizationExecuteWithPrivileges": authorizationExecuteWithPrivileges, + "AuthorizationFree": authorizationFree, + "fileno": fileno, + "fclose": fclose, + } { + assert.NotNil(t, fn, "%s must resolve", name) + } +} + +// A request with no interaction allowed exercises the whole call — the rights and +// environment structs, and the OSStatus that comes back — without a dialog anybody +// has to answer. What the system decides is its business; that it decides at all is +// what this asserts. +func TestAuthorizationCreateWithoutInteraction(t *testing.T) { + if err := load(); err != nil { + t.Skipf("Security.framework did not open: %v", err) + } + + var pinner runtime.Pinner + defer pinner.Unpin() + + rights := itemSet(&pinner, authorizationItem{name: cString(&pinner, rightExecute)}) + environment := itemSet(&pinner, promptItem(&pinner)) + require.EqualValues(t, 1, rights.count, "the rights struct layout must match the C one") + + var authorization uintptr + status := authorizationCreate(rights, environment, flagDefaults|flagExtendRights, &authorization) + + switch status { + case errAuthorizationSuccess: + // Credentials were already cached for this session. + authorizationFree(authorization, flagDestroyRights) + case errAuthorizationDenied, errAuthorizationInteractionNotAllowed: + // The expected answers when nobody may be asked. + default: + require.Failf(t, "unknown OSStatus", "AuthorizationCreate returned %d, want a status we recognise", status) + } +} + +// Asking with a right nobody has must not be mistaken for a declined prompt: the +// caller would report nothing at all. +func TestAuthorizeUnknownRightIsNotDeclined(t *testing.T) { + if err := load(); err != nil { + t.Skipf("Security.framework did not open: %v", err) + } + + var pinner runtime.Pinner + defer pinner.Unpin() + + rights := itemSet(&pinner, authorizationItem{name: cString(&pinner, "io.netbird.right.that.does.not.exist")}) + + var authorization uintptr + status := authorizationCreate(rights, nil, flagDefaults|flagExtendRights, &authorization) + if status == errAuthorizationSuccess { + authorizationFree(authorization, flagDestroyRights) + } + assert.NotEqual(t, int32(errAuthorizationSuccess), status, "a right that does not exist must not be granted") +} + +func TestMechanismAvailable(t *testing.T) { + assert.True(t, mechanismAvailable(), "the trampoline exists on every macOS") +} + +// The one-shot's report is what stands in for an exit status here, so a run that +// says nothing must not read as success. +func TestCheckApplied(t *testing.T) { + require.NoError(t, checkApplied(AppliedMarker+"\n"), "the report the one-shot prints") + require.NoError(t, checkApplied("some warning\n"+AppliedMarker+"\n"), "the report after other output") + + assert.Error(t, checkApplied(""), "a run that printed nothing did not apply the change") + assert.Error(t, checkApplied("dyld: library not loaded\n"), "output that is not the report") +} + +// A panic out of the FFI layer has to reach the caller as "no mechanism", which is +// the outcome that offers the user the command instead of taking the window down. +func TestGuardTurnsAPanicIntoUnavailable(t *testing.T) { + err := guard("pretending to call something", func() error { + panic("purego: signature it cannot map") + }) + + require.ErrorIs(t, err, ErrUnavailable, "a panic must read as a missing mechanism") + assert.Contains(t, err.Error(), "pretending to call something", "what panicked") +} + +// guard wraps every darwin path, so what a caller switches on has to survive it. +func TestGuardPassesErrorsThrough(t *testing.T) { + sentinel := errors.New("the call itself failed") + assert.ErrorIs(t, guard("calling", func() error { return sentinel }), sentinel, + "the error it was given") + assert.ErrorIs(t, guard("calling", func() error { return ErrDeclined }), ErrDeclined, + "a declined prompt stays declined") + assert.NoError(t, guard("calling", func() error { return nil }), "a call that worked") +} diff --git a/client/internal/elevate/run_unix.go b/client/internal/elevate/run_unix.go new file mode 100644 index 000000000..b2de09a49 --- /dev/null +++ b/client/internal/elevate/run_unix.go @@ -0,0 +1,117 @@ +//go:build linux + +package elevate + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" +) + +// pkexec exit codes that are about the authorization rather than about the program +// we asked it to run. The manual page reserves both. +const ( + // exitDismissed is returned when the user dismissed the authentication + // dialog. + exitDismissed = 126 + // exitNotAuthorized is returned when the authorization was not obtained. That + // covers the user saying no as well as pkexec having had nobody to ask: see + // noAgentMarkers. + exitNotAuthorized = 127 +) + +// exitNotAuthorized covers three different endings that only pkexec's own words +// tell apart, so they are matched here. Read with LC_ALL=C so the words are the +// ones written below. +// +// refusedMarker is a refusal: the user said no, gave up on the password, or holds +// an account that may not elevate at all. +const refusedMarker = "Not authorized" + +// noAgentMarkers say pkexec had no way to ask: no agent registered for the +// session, and no controlling terminal for the textual agent it falls back to. +var noAgentMarkers = []string{"authentication agent", "controlling terminal"} + +// run asks polkit to run self as root. pkexec hands the request to the session's +// polkit agent, which is what prompts and what collects any password; we see only +// its verdict. +// +// The environment is otherwise deliberately not passed through: pkexec clears it +// bar a small allowlist, and the one-shot needs nothing from it. +func run(ctx context.Context, self string, args []string) error { + pkexec, err := exec.LookPath("pkexec") + if err != nil { + return fmt.Errorf("%w: pkexec is not installed", ErrUnavailable) + } + + cmd := exec.CommandContext(ctx, pkexec, append([]string{self}, args...)...) + // C locale so pkexec's own diagnostics are the ones noAgentMarkers knows. + cmd.Env = append(os.Environ(), "LC_ALL=C") + var stderr strings.Builder + cmd.Stderr = &stderr + // The one-shot reports itself on stdout for macOS's sake, where there is no + // exit status to read. Here there is one, so that line is noise. + cmd.Stdout = io.Discard + + err = cmd.Run() + if err == nil { + return nil + } + + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + return fmt.Errorf("run pkexec: %w", err) + } + + // Matched against everything pkexec said, reported as one line: a complaint + // that is not the first thing printed still has to be recognised, and reading + // it as a refusal would swallow it. + full := stderr.String() + out := firstLine(full) + + switch exitErr.ExitCode() { + case exitDismissed: + return ErrDeclined + case exitNotAuthorized: + return notAuthorized(full, out) + default: + return fmt.Errorf("elevated netbird exited with %d: %s", exitErr.ExitCode(), out) + } +} + +// notAuthorized sorts out the three endings pkexec reports as exitNotAuthorized. +// +// It also returns that code when the authorization succeeded and it then could +// not run the program, so a refusal has to be recognised rather than assumed: +// reading every one of these as "the user said no" would revert the control in +// silence on a host where elevation is broken. +func notAuthorized(full, out string) error { + switch { + case hasAny(full, noAgentMarkers): + return fmt.Errorf("%w: polkit had no way to ask: %s", ErrUnavailable, out) + case out == noOutput, strings.Contains(full, refusedMarker): + // The user said no, which needs no message; that an account barred from + // elevating altogether lands here too is why the reason is kept. + return fmt.Errorf("%w: %s", ErrDeclined, out) + default: + return fmt.Errorf("pkexec could not run elevated netbird: %s", out) + } +} + +func hasAny(s string, markers []string) bool { + for _, marker := range markers { + if strings.Contains(s, marker) { + return true + } + } + return false +} + +func mechanismAvailable() bool { + _, err := exec.LookPath("pkexec") + return err == nil +} diff --git a/client/internal/elevate/run_unix_test.go b/client/internal/elevate/run_unix_test.go new file mode 100644 index 000000000..c868f9a74 --- /dev/null +++ b/client/internal/elevate/run_unix_test.go @@ -0,0 +1,110 @@ +//go:build linux + +package elevate + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakePkexec puts a pkexec on PATH that exits with the given code, so the +// mapping from polkit's exit codes onto our errors can be exercised without a +// polkit agent. +func fakePkexec(t *testing.T, exitCode int, stderr string) { + t.Helper() + + dir := t.TempDir() + script := fmt.Sprintf("#!/bin/sh\necho %s >&2\nexit %d\n", shellQuote(stderr), exitCode) + require.NoError(t, os.WriteFile(filepath.Join(dir, "pkexec"), []byte(script), 0o700), "write the fake pkexec") + t.Setenv("PATH", dir) +} + +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +func TestRunMapsPkexecExitCodes(t *testing.T) { + tests := []struct { + name string + exitCode int + stderr string + wantErr error + }{ + {name: "applied", exitCode: 0}, + { + name: "dialog dismissed", + exitCode: exitDismissed, + stderr: "Error executing command as another user: Request dismissed", + wantErr: ErrDeclined, + }, + { + // What a graphical agent reports for a cancelled prompt. Not a + // failure: the user was asked and answered. + name: "prompt cancelled", + exitCode: exitNotAuthorized, + stderr: "Error executing command as another user: Not authorized", + wantErr: ErrDeclined, + }, + { + // The same status, but pkexec never got to ask anybody. + name: "no agent and no terminal to fall back on", + exitCode: exitNotAuthorized, + stderr: "Error creating textual authentication agent: Error opening current controlling terminal for the process (`/dev/tty'): No such device or address", + wantErr: ErrUnavailable, + }, + { + // And the same status again once the authorization succeeded and + // pkexec could not run what it had been authorized to run. Reading + // that as a refusal would revert the control in silence on a host + // where elevation is broken. + name: "authorized but not runnable", + exitCode: exitNotAuthorized, + stderr: "Error executing command as another user: No such file or directory", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fakePkexec(t, tt.exitCode, tt.stderr) + + err := run(context.Background(), "/nonexistent/netbird-ui", []string{"--flag"}) + switch { + case tt.wantErr != nil: + require.ErrorIs(t, err, tt.wantErr, "exit %d said %q", tt.exitCode, tt.stderr) + case tt.exitCode == 0: + require.NoError(t, err, "a pkexec that exited cleanly applied the change") + default: + require.Error(t, err, "exit %d said %q", tt.exitCode, tt.stderr) + assert.NotErrorIs(t, err, ErrDeclined, "not the user's answer") + assert.NotErrorIs(t, err, ErrUnavailable, "not a missing mechanism") + } + }) + } +} + +// An exit code that is not polkit's is the one-shot's own failure, and has to +// stay distinguishable from a declined prompt: the caller reports it. +func TestRunReportsOneShotFailure(t *testing.T) { + fakePkexec(t, 3, "the one-shot said no") + + err := run(context.Background(), "/nonexistent/netbird-ui", nil) + + require.Error(t, err, "a one-shot that failed is not a prompt that was answered") + assert.NotErrorIs(t, err, ErrDeclined, "not the user's answer") + assert.NotErrorIs(t, err, ErrUnavailable, "not a missing mechanism") +} + +func TestRunWithoutPkexecIsUnavailable(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + + err := run(context.Background(), "/nonexistent/netbird-ui", nil) + require.ErrorIs(t, err, ErrUnavailable, "no pkexec means no mechanism") + assert.False(t, mechanismAvailable(), "mechanismAvailable without pkexec on PATH") +} diff --git a/client/internal/elevate/run_unsupported.go b/client/internal/elevate/run_unsupported.go new file mode 100644 index 000000000..d1daf3184 --- /dev/null +++ b/client/internal/elevate/run_unsupported.go @@ -0,0 +1,19 @@ +//go:build !windows && !darwin && !linux + +package elevate + +import "context" + +// run reports that this platform has no elevation prompt to drive. +// +// The desktop app is the only caller and is not built for any of these: mobile +// and WASM have no local user to ask, and the FreeBSD client ships without a UI. +// pkexec would be the mechanism there, and run_unix.go is what to widen if that +// changes. +func run(context.Context, string, []string) error { + return ErrUnavailable +} + +func mechanismAvailable() bool { + return false +} diff --git a/client/internal/elevate/run_windows.go b/client/internal/elevate/run_windows.go new file mode 100644 index 000000000..eef4c23ce --- /dev/null +++ b/client/internal/elevate/run_windows.go @@ -0,0 +1,187 @@ +package elevate + +import ( + "context" + "errors" + "fmt" + "runtime" + "unsafe" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" +) + +const ( + // seeMaskNoCloseProcess keeps the started process's handle open in + // hProcess so we can wait for it. + seeMaskNoCloseProcess = 0x00000040 + // seeMaskNoAsync makes ShellExecuteExW finish its work before returning, + // which it must when the calling thread does not pump messages. + seeMaskNoAsync = 0x00000100 + // seeMaskFlagNoUI suppresses the shell's own error dialogs; the UAC consent + // dialog is not one of them and still appears. + seeMaskFlagNoUI = 0x00000400 + + // swHide: the one-shot has no window to show. + swHide = 0 +) + +// shellExecuteInfoW mirrors SHELLEXECUTEINFOW. The field order and Go's own +// padding match the C layout on both 386 and amd64. +type shellExecuteInfoW struct { + cbSize uint32 + fMask uint32 + hwnd windows.HWND + lpVerb *uint16 + lpFile *uint16 + lpParameters *uint16 + lpDirectory *uint16 + nShow int32 + hInstApp windows.Handle + lpIDList uintptr + lpClass *uint16 + hkeyClass windows.Handle + dwHotKey uint32 + hIconOrMonitor windows.Handle + hProcess windows.Handle +} + +var ( + shell32 = windows.NewLazySystemDLL("shell32.dll") + procShellExecuteEx = shell32.NewProc("ShellExecuteExW") +) + +// run starts self elevated with the "runas" verb, which is what raises the UAC +// consent dialog, and waits for it to finish. Windows decides whether consent is +// enough or an administrator's credentials are needed, and collects them itself. +func run(ctx context.Context, self string, args []string) error { + verb, err := windows.UTF16PtrFromString("runas") + if err != nil { + return fmt.Errorf("encode verb: %w", err) + } + file, err := windows.UTF16PtrFromString(self) + if err != nil { + return fmt.Errorf("encode %s: %w", self, err) + } + params, err := windows.UTF16PtrFromString(windows.ComposeCommandLine(args)) + if err != nil { + return fmt.Errorf("encode arguments: %w", err) + } + + info := shellExecuteInfoW{ + fMask: seeMaskNoCloseProcess | seeMaskNoAsync | seeMaskFlagNoUI, + hwnd: ownerWindow(), + lpVerb: verb, + lpFile: file, + lpParameters: params, + nShow: swHide, + } + info.cbSize = uint32(unsafe.Sizeof(info)) + + process, err := shellExecute(&info) + if err != nil { + return err + } + defer func() { + if err := windows.CloseHandle(process); err != nil { + log.Debugf("close elevated process handle: %v", err) + } + }() + + return waitForProcess(ctx, process) +} + +// shellExecute performs the call itself. ShellExecuteExW wants COM initialised on +// the calling thread, so the goroutine is pinned to one for the duration and COM +// is set up on it; an "already initialised, different mode" answer is fine, +// because then somebody else has done it for us. +func shellExecute(info *shellExecuteInfoW) (windows.Handle, error) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + switch err := windows.CoInitializeEx(0, windows.COINIT_APARTMENTTHREADED); { + case err == nil, isHResult(err, windows.S_FALSE): + // Ours, or already initialised in the same mode: either way this call + // counts and has to be balanced. + defer windows.CoUninitialize() + case isHResult(err, windows.RPC_E_CHANGED_MODE): + // The thread is already in the other apartment model. ShellExecuteExW + // works there too, and there is nothing of ours to balance. + default: + return 0, fmt.Errorf("initialise COM: %w", err) + } + + ret, _, lastErr := procShellExecuteEx.Call(uintptr(unsafe.Pointer(info))) + if ret != 0 { + return info.hProcess, nil + } + + if errors.Is(lastErr, windows.ERROR_CANCELLED) { + return 0, ErrDeclined + } + return 0, fmt.Errorf("run elevated: %w", lastErr) +} + +// ownerWindow returns this process's foreground window, and 0 when the window in +// front belongs to somebody else or cannot be attributed. ShellExecuteExW takes it +// as the parent for the UI it raises, which is what keeps the consent dialog in +// front of the window the user was just clicking in instead of behind it. It is +// also what a remote-desktop session needs to place the dialog at all when the +// secure desktop is switched off. +func ownerWindow() windows.HWND { + hwnd := windows.GetForegroundWindow() + if hwnd == 0 { + return 0 + } + + var pid uint32 + if _, err := windows.GetWindowThreadProcessId(hwnd, &pid); err != nil { + log.Debugf("cannot attribute the foreground window, raising the prompt without an owner: %v", err) + return 0 + } + if pid != windows.GetCurrentProcessId() { + return 0 + } + return hwnd +} + +// isHResult reports whether err carries the given HRESULT. CoInitializeEx +// returns its HRESULT as an Errno, so the comparison is on the raw value. +func isHResult(err error, hresult windows.Handle) bool { + var errno windows.Errno + return errors.As(err, &errno) && uintptr(errno) == uintptr(hresult) +} + +func waitForProcess(ctx context.Context, process windows.Handle) error { + // The wait is interruptible so a cancelled context stops us waiting on a + // consent dialog nobody is going to answer. The elevated process is not + // ours to kill, and it either applies the change or does not. + for { + event, err := windows.WaitForSingleObject(process, 250) + if err != nil { + return fmt.Errorf("wait for the elevated process: %w", err) + } + if event == uint32(windows.WAIT_OBJECT_0) { + break + } + if err := ctx.Err(); err != nil { + return err + } + } + + var code uint32 + if err := windows.GetExitCodeProcess(process, &code); err != nil { + return fmt.Errorf("read the elevated process's exit code: %w", err) + } + if code != 0 { + return fmt.Errorf("elevated netbird exited with %d", code) + } + return nil +} + +// mechanismAvailable is true on Windows: UAC prompts for consent when the user +// is an administrator and for an administrator's credentials when they are not, +// so there is always something to ask. +func mechanismAvailable() bool { + return true +} diff --git a/client/internal/elevate/trusted.go b/client/internal/elevate/trusted.go new file mode 100644 index 000000000..c11054c45 --- /dev/null +++ b/client/internal/elevate/trusted.go @@ -0,0 +1,40 @@ +package elevate + +import ( + "fmt" + "os" + "path/filepath" +) + +// trustedSelf returns the path of this executable, provided it is one we are +// willing to have run as root. +// +// The check is what keeps elevation from becoming a way to launder someone +// else's code into a root process: the user consents to NetBird being elevated, +// having been shown NetBird's name, so what runs must be the file NetBird was +// installed as and not something a third party could have swapped for it. An +// executable only its owner can write is that; anything wider is refused, and +// the caller falls back to showing the command instead. +// +// The owner writing to their own executable is not part of that threat: code +// running as the user can already prompt them for anything, and could just as +// well ask them to run the command by hand. What matters is that no *other* +// unprivileged account can reach it. +func trustedSelf() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", fmt.Errorf("locate this executable: %w", err) + } + + // Resolve symlinks so the checks below apply to the file that would actually + // be executed, not to a link somebody else may control. + resolved, err := filepath.EvalSymlinks(exe) + if err != nil { + return "", fmt.Errorf("resolve %s: %w", exe, err) + } + + if err := checkOnlyOwnerWritable(resolved); err != nil { + return "", fmt.Errorf("%w: %s cannot be trusted to run as root: %w", ErrUnavailable, resolved, err) + } + return resolved, nil +} diff --git a/client/internal/elevate/trusted_group_darwin.go b/client/internal/elevate/trusted_group_darwin.go new file mode 100644 index 000000000..a4b387ec4 --- /dev/null +++ b/client/internal/elevate/trusted_group_darwin.go @@ -0,0 +1,10 @@ +package elevate + +// adminWriteGIDs are the groups whose write access to an executable does not +// widen who could authorize elevating it. +// +// macOS installs applications as root:admin, mode 0775, /Applications included, +// so requiring owner-only write would reject every normal install. Group admin +// (gid 80) is exactly the set of accounts that can answer the authentication +// dialog, so its write access grants nothing the prompt would not. +var adminWriteGIDs = []uint32{0, 80} diff --git a/client/internal/elevate/trusted_group_unix.go b/client/internal/elevate/trusted_group_unix.go new file mode 100644 index 000000000..7aa336423 --- /dev/null +++ b/client/internal/elevate/trusted_group_unix.go @@ -0,0 +1,9 @@ +//go:build !windows && !darwin + +package elevate + +// adminWriteGIDs are the groups whose write access to an executable does not +// widen who could authorize elevating it. Only root's own group qualifies here: +// a distribution installs into root-owned directories, and there is no +// system-wide administrators group that both writes them and answers polkit. +var adminWriteGIDs = []uint32{0} diff --git a/client/internal/elevate/trusted_unix.go b/client/internal/elevate/trusted_unix.go new file mode 100644 index 000000000..f9d1a1b7e --- /dev/null +++ b/client/internal/elevate/trusted_unix.go @@ -0,0 +1,119 @@ +//go:build !windows + +package elevate + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strconv" + "syscall" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/getent" +) + +// checkOnlyOwnerWritable reports an error unless path, and every directory leading +// to it, is owned by either root or this user and writable by nobody who could not +// already act as its owner. A writable directory is as good as a writable file, +// since anything in it can be replaced, so the whole chain is checked. +func checkOnlyOwnerWritable(path string) error { + self := uint32(os.Getuid()) + + for dir := path; ; dir = filepath.Dir(dir) { + info, err := os.Lstat(dir) + if err != nil { + return fmt.Errorf("stat %s: %w", dir, err) + } + + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return errors.New("file ownership is unavailable on this platform") + } + if stat.Uid != 0 && stat.Uid != self { + return fmt.Errorf("%s is owned by uid %d, neither root nor this user", dir, stat.Uid) + } + + if err := checkWriteBits(dir, info, stat.Uid, stat.Gid); err != nil { + return err + } + + if parent := filepath.Dir(dir); parent == dir { + return nil + } + } +} + +func checkWriteBits(path string, info os.FileInfo, uid, gid uint32) error { + // On a directory the sticky bit stands in for the write bits: whoever may + // write there still cannot replace an entry they do not own, which is the + // only thing that would matter to us. /tmp is the usual example. + sticky := info.IsDir() && info.Mode()&os.ModeSticky != 0 + + return writeBitsAllow(path, info.Mode().Perm(), sticky, groupWriteAllowed(uid, gid)) +} + +// writeBitsAllow decides on the permission bits alone, given whether the group's +// write access has been vouched for. +func writeBitsAllow(path string, perm os.FileMode, sticky, groupAllowed bool) error { + if sticky { + return nil + } + if perm&0o020 != 0 && !groupAllowed { + return fmt.Errorf("%s is writable by a group with members other than its owner (%v)", path, perm) + } + if perm&0o002 != 0 { + return fmt.Errorf("%s is world-writable (%v)", path, perm) + } + return nil +} + +// groupWriteAllowed reports whether a group's write access to a file owned by uid +// puts it in reach of anyone who could not already act as that owner. +// +// Two ways it does not. A group in adminWriteGIDs holds the accounts that can +// answer the elevation prompt anyway. And a user private group is how Debian, +// Ubuntu and Fedora ship: their umask of 002 makes a home directory and +// everything built in it group-writable, so refusing that would refuse every +// build not installed from a package. +func groupWriteAllowed(uid, gid uint32) bool { + if slices.Contains(adminWriteGIDs, gid) { + return true + } + + group, err := getent.LookupGroupID(strconv.FormatUint(uint64(gid), 10)) + if err != nil { + log.Debugf("cannot look up group %d, treating it as shared: %v", gid, err) + return false + } + owner, err := getent.LookupUserID(strconv.FormatUint(uint64(uid), 10)) + if err != nil { + log.Debugf("cannot look up uid %d, treating its group as shared: %v", uid, err) + return false + } + + if group.Name != owner.Username { + return false + } + return !groupHasOtherMembers(group.Name, owner.Username) +} + +// groupHasOtherMembers reports whether the group lists a member besides owner. +// +// Sharing the owner's name is what a user private group is recognised by, and it +// says nothing about who is in it: a group that has since gained a member is +// still named that way, and that member can write whatever the group can. So the +// membership is read rather than assumed. A group whose members cannot be +// listed, because no source on this host describes it, is treated as shared: +// the name alone cannot vouch for who writes through it. +func groupHasOtherMembers(name, owner string) bool { + members, err := getent.GroupMembers(name) + if err != nil { + log.Debugf("cannot list the members of group %q, treating it as shared: %v", name, err) + return true + } + return slices.ContainsFunc(members, func(member string) bool { return member != owner }) +} diff --git a/client/internal/elevate/trusted_unix_test.go b/client/internal/elevate/trusted_unix_test.go new file mode 100644 index 000000000..7c0c5a966 --- /dev/null +++ b/client/internal/elevate/trusted_unix_test.go @@ -0,0 +1,148 @@ +//go:build !windows + +package elevate + +import ( + "os" + "os/user" + "path/filepath" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ownerOnlyDir is t.TempDir() with the write bits tightened. testing creates its +// numbered directory with 0777 minus the umask, so under the common 002 umask it +// is group-writable and would fail the check under test on its own. +func ownerOnlyDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.Chmod(dir, 0o755), "tighten the temporary directory") + return dir +} + +// writeExecutable creates a plain executable file, the shape trustedSelf checks. +func writeExecutable(t *testing.T, dir string) string { + t.Helper() + path := filepath.Join(dir, "netbird-ui") + require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755), "write the executable") + require.NoError(t, os.Chmod(path, 0o755), "set the executable's mode") + return path +} + +func TestCheckOnlyOwnerWritableAcceptsOwnerOnly(t *testing.T) { + err := checkOnlyOwnerWritable(writeExecutable(t, ownerOnlyDir(t))) + assert.NoError(t, err, "an owner-only writable executable is trustworthy") +} + +func TestCheckOnlyOwnerWritableRejectsWorldWritableFile(t *testing.T) { + path := writeExecutable(t, ownerOnlyDir(t)) + require.NoError(t, os.Chmod(path, 0o777), "make the executable world-writable") + + assert.Error(t, checkOnlyOwnerWritable(path), "a world-writable executable must be refused") +} + +// The permission policy on its own, without a filesystem to arrange: whether the +// group has been vouched for is the only thing that makes group write acceptable. +func TestWriteBitsAllow(t *testing.T) { + tests := []struct { + name string + perm os.FileMode + sticky bool + groupAllowed bool + wantErr bool + }{ + {name: "owner only", perm: 0o755}, + {name: "group write in a private group", perm: 0o775, groupAllowed: true}, + {name: "group write in a shared group", perm: 0o775, wantErr: true}, + {name: "world write", perm: 0o777, groupAllowed: true, wantErr: true}, + {name: "world write on a sticky directory", perm: 0o777, sticky: true}, + {name: "group write on a sticky directory", perm: 0o775, sticky: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := writeBitsAllow("/path", tt.perm, tt.sticky, tt.groupAllowed) + if tt.wantErr { + assert.Error(t, err, "perm %v, sticky %v, group allowed %v", tt.perm, tt.sticky, tt.groupAllowed) + return + } + assert.NoError(t, err, "perm %v, sticky %v, group allowed %v", tt.perm, tt.sticky, tt.groupAllowed) + }) + } +} + +// A build under a home directory on a distribution with a 002 umask, which is what +// a locally built or tarball-installed binary looks like. Its group has no members +// but its owner, so it is as good as owner-only. +// +// Whether this host is such a distribution is read from the environment rather than +// from groupWriteAllowed: asking the function under test whether to run would let +// it skip its own coverage away if it regressed to refusing everything. +func TestCheckOnlyOwnerWritableAcceptsOwnPrivateGroup(t *testing.T) { + requirePrivatePrimaryGroup(t) + + dir := ownerOnlyDir(t) + path := writeExecutable(t, dir) + require.NoError(t, os.Chmod(dir, 0o775), "make the directory group-writable") + require.NoError(t, os.Chmod(path, 0o775), "make the executable group-writable") + + err := checkOnlyOwnerWritable(path) + assert.NoError(t, err, "group write in the owner's own private group reaches nobody else") +} + +// A group whose membership no source can answer for is treated as shared: the +// private-group allowance must not stand on a name nobody can vouch for. The +// membership listing itself lives in the getent package and is tested there. +func TestGroupHasOtherMembersRejectsAnUnknownGroup(t *testing.T) { + assert.True(t, groupHasOtherMembers("nonexistent_group_xyzzy_12345", "vma"), + "a group no source describes") +} + +// A writable directory is as good as a writable file: whoever can write the +// directory can put a different binary at the same path. +func TestCheckOnlyOwnerWritableRejectsWritableDirectory(t *testing.T) { + dir := filepath.Join(ownerOnlyDir(t), "bin") + require.NoError(t, os.Mkdir(dir, 0o755), "create the directory") + path := writeExecutable(t, dir) + require.NoError(t, os.Chmod(dir, 0o777), "make the directory world-writable") + + assert.Error(t, checkOnlyOwnerWritable(path), "an executable in a world-writable directory must be refused") +} + +// A sticky world-writable directory is exempt: the sticky bit is what stops one +// user replacing another's entries. /tmp is why this matters. +func TestCheckOnlyOwnerWritableAcceptsStickyDirectory(t *testing.T) { + dir := filepath.Join(ownerOnlyDir(t), "sticky") + require.NoError(t, os.Mkdir(dir, 0o755), "create the directory") + path := writeExecutable(t, dir) + require.NoError(t, os.Chmod(dir, 0o777|os.ModeSticky), "make the directory sticky and world-writable") + + err := checkOnlyOwnerWritable(path) + assert.NoError(t, err, "the sticky bit stops another user replacing the executable") +} + +func TestCheckOnlyOwnerWritableRejectsMissingFile(t *testing.T) { + err := checkOnlyOwnerWritable(filepath.Join(ownerOnlyDir(t), "absent")) + assert.Error(t, err, "an executable that is not there must be refused") +} + +// requirePrivatePrimaryGroup skips unless this user's primary group is their own, +// which is what the user-private-group allowance is about. +func requirePrivatePrimaryGroup(t *testing.T) { + t.Helper() + + self, err := user.Current() + require.NoError(t, err, "look up the test user") + group, err := user.LookupGroupId(strconv.Itoa(os.Getgid())) + require.NoError(t, err, "look up the test user's primary group") + + if group.Name != self.Username { + t.Skipf("the test user's primary group is %q, not their own, so there is nothing to assert here", group.Name) + } + if groupHasOtherMembers(group.Name, self.Username) { + t.Skipf("group %q has other members, so it is not a private group", group.Name) + } +} diff --git a/client/internal/elevate/trusted_windows.go b/client/internal/elevate/trusted_windows.go new file mode 100644 index 000000000..8fb05fd88 --- /dev/null +++ b/client/internal/elevate/trusted_windows.go @@ -0,0 +1,215 @@ +package elevate + +import ( + "errors" + "fmt" + "path/filepath" + "slices" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + // fileDeleteChild is FILE_DELETE_CHILD, which x/sys does not define: the + // right to delete an entry of a directory without holding DELETE on it. + fileDeleteChild = 0x00000040 + + // accessAllowedCallbackACEType is an allow ACE with a condition appended to + // the ACCESS_ALLOWED_ACE layout, so its trustee is still at SidStart. + accessAllowedCallbackACEType = 0x9 + + // The allow ACE types that carry object GUIDs ahead of the trustee, so the + // SID is not at SidStart. They occur on directory-service objects rather + // than files, and are refused rather than skipped: see aceTrustee. + accessAllowedObjectACEType = 0x5 + accessAllowedCallbackObjectACEType = 0xB +) + +// fileWriteAccess are the rights that let a trustee rewrite or replace a file, +// or take it over and then do so. +const fileWriteAccess = windows.FILE_WRITE_DATA | windows.FILE_APPEND_DATA | + windows.DELETE | windows.WRITE_DAC | windows.WRITE_OWNER | + windows.GENERIC_WRITE | windows.GENERIC_ALL + +// dirWriteAccess are the rights over a directory that let a trustee replace an +// entry somebody else owns. Creating a new entry is not one of them, which is +// what the Unix sticky bit says in one bit: the root of every volume grants +// BUILTIN\Users the right to add directories under it, and that reaches nothing +// already there. +const dirWriteAccess = fileDeleteChild | windows.DELETE | + windows.WRITE_DAC | windows.WRITE_OWNER | windows.GENERIC_ALL + +// trustedInstallerSID owns much of what Windows itself installs. x/sys has no +// well-known constant for it. +const trustedInstallerSID = "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464" + +// checkOnlyOwnerWritable reports an error unless path, and every directory +// leading to it, is owned by an account that can elevate (or by this user) and +// grants write access to nobody else. A writable directory is as good as a +// writable file, since an entry in it can be replaced, so the whole chain is +// checked. +func checkOnlyOwnerWritable(path string) error { + owners, err := trustedOwners() + if err != nil { + return err + } + writers, err := trustedWriters(owners) + if err != nil { + return err + } + + writeAccess := windows.ACCESS_MASK(fileWriteAccess) + for target := path; ; target = filepath.Dir(target) { + if err := checkSecurity(target, writeAccess, owners, writers); err != nil { + return err + } + if parent := filepath.Dir(target); parent == target { + return nil + } + writeAccess = dirWriteAccess + } +} + +// trustedOwners are the accounts we accept as the owner of the executable and of +// the directories above it: the ones that can already answer the UAC prompt, +// plus this user, whose own executable is theirs to write. Code running as the +// user could prompt them for anything anyway; what matters is that no *other* +// unprivileged account can reach it. +func trustedOwners() ([]*windows.SID, error) { + self, err := currentUserSID() + if err != nil { + return nil, err + } + + owners := []*windows.SID{self} + for _, wellKnown := range []windows.WELL_KNOWN_SID_TYPE{ + windows.WinLocalSystemSid, + windows.WinBuiltinAdministratorsSid, + } { + sid, err := windows.CreateWellKnownSid(wellKnown) + if err != nil { + return nil, fmt.Errorf("build well-known SID %d: %w", wellKnown, err) + } + owners = append(owners, sid) + } + + installer, err := windows.StringToSid(trustedInstallerSID) + if err != nil { + return nil, fmt.Errorf("parse TrustedInstaller SID: %w", err) + } + return append(owners, installer), nil +} + +// trustedWriters are the trustees whose write access does not widen who could +// decide what runs behind the prompt. The owners, and CREATOR OWNER, which +// resolves to the object's owner and is therefore already vetted. +func trustedWriters(owners []*windows.SID) ([]*windows.SID, error) { + creatorOwner, err := windows.CreateWellKnownSid(windows.WinCreatorOwnerSid) + if err != nil { + return nil, fmt.Errorf("build the CREATOR OWNER SID: %w", err) + } + return append(slices.Clone(owners), creatorOwner), nil +} + +func checkSecurity(path string, writeAccess windows.ACCESS_MASK, owners, writers []*windows.SID) error { + sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, + windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION) + if err != nil { + return fmt.Errorf("read security descriptor of %s: %w", path, err) + } + + owner, _, err := sd.Owner() + if err != nil { + return fmt.Errorf("read owner of %s: %w", path, err) + } + if !containsSID(owners, owner) { + return fmt.Errorf("%s is owned by %s, which is neither this user nor an account that can elevate", path, owner) + } + + dacl, _, err := sd.DACL() + if err != nil { + return fmt.Errorf("read DACL of %s: %w", path, err) + } + // A NULL DACL grants everyone everything; only an absent security + // descriptor would have got us here without one, and neither is trustworthy. + if dacl == nil { + return fmt.Errorf("%s has no DACL, so it grants write access to everyone", path) + } + + return checkDACL(path, dacl, writeAccess, writers) +} + +// checkDACL refuses an ACL that grants write access to a trustee outside +// writers. +// +// An allowlist, because the trustees that must not have it cannot be listed: an +// ACE naming an ordinary user account hands that account the same power as one +// naming Everyone, and only the accounts that may hold it are knowable. +func checkDACL(path string, dacl *windows.ACL, writeAccess windows.ACCESS_MASK, writers []*windows.SID) error { + for i := uint32(0); i < uint32(dacl.AceCount); i++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(dacl, i, &ace); err != nil { + return fmt.Errorf("read ACE %d of %s: %w", i, path, err) + } + // An inherit-only ACE says what children of this object get, not what + // this object grants. + if ace.Header.AceFlags&windows.INHERIT_ONLY_ACE != 0 { + continue + } + if ace.Mask&writeAccess == 0 { + continue + } + // Only an allow ACE grants anything; a deny ACE narrows what one gave. + if !isAllowACE(ace.Header.AceType) { + continue + } + + trustee, err := aceTrustee(ace) + if err != nil { + return fmt.Errorf("read the trustee of ACE %d of %s: %w", i, path, err) + } + if !containsSID(writers, trustee) { + return fmt.Errorf("%s grants write access to %s", path, trustee) + } + } + return nil +} + +// isAllowACE reports whether an ACE type grants rights, rather than denying, +// auditing or labelling them. +func isAllowACE(aceType uint8) bool { + switch aceType { + case windows.ACCESS_ALLOWED_ACE_TYPE, accessAllowedCallbackACEType, + accessAllowedObjectACEType, accessAllowedCallbackObjectACEType: + return true + default: + return false + } +} + +// aceTrustee returns who an allow ACE grants its rights to. An ACE whose trustee +// cannot be located is an error rather than something to skip past: being unable +// to read who is being given write access is a refusal. +func aceTrustee(ace *windows.ACCESS_ALLOWED_ACE) (*windows.SID, error) { + switch ace.Header.AceType { + case windows.ACCESS_ALLOWED_ACE_TYPE, accessAllowedCallbackACEType: + //nolint:gosec // SidStart is the first uint32 of the variable-length SID that follows the ACE header. + return (*windows.SID)(unsafe.Pointer(&ace.SidStart)), nil + default: + return nil, errors.New("an object-type allow ACE does not carry its trustee where we can read it") + } +} + +func containsSID(sids []*windows.SID, sid *windows.SID) bool { + return slices.ContainsFunc(sids, sid.Equals) +} + +func currentUserSID() (*windows.SID, error) { + token := windows.GetCurrentProcessToken() + user, err := token.GetTokenUser() + if err != nil { + return nil, fmt.Errorf("read this process's user: %w", err) + } + return user.User.Sid, nil +} diff --git a/client/internal/elevate/trusted_windows_test.go b/client/internal/elevate/trusted_windows_test.go new file mode 100644 index 000000000..946e7b7c8 --- /dev/null +++ b/client/internal/elevate/trusted_windows_test.go @@ -0,0 +1,126 @@ +package elevate + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +// A file the test user created under their own profile, which is what a per-user +// install looks like. The whole chain up to the volume root is walked, so this is +// also what says the walk does not refuse an ordinary Windows installation: the +// root of every volume grants BUILTIN\Users rights that are not ours to worry +// about. +func TestCheckOnlyOwnerWritableAcceptsOwnFile(t *testing.T) { + err := checkOnlyOwnerWritable(writeExecutable(t)) + assert.NoError(t, err, "a file the test user owns, under directories only administrators can write") +} + +// Write access held by an account that cannot answer the UAC prompt means that +// account decides what runs behind it, whoever the ACE names. The trustees that +// must not have it cannot be listed, so the check names the ones that may. +func TestCheckOnlyOwnerWritableRejectsUntrustedWriters(t *testing.T) { + tests := []struct { + name string + wellKnown windows.WELL_KNOWN_SID_TYPE + }{ + {name: "everyone", wellKnown: windows.WinWorldSid}, + {name: "authenticated users", wellKnown: windows.WinAuthenticatedUserSid}, + {name: "builtin users", wellKnown: windows.WinBuiltinUsersSid}, + // A service account, which no denylist of the obvious groups would name + // and which cannot elevate any more than Everyone can. + {name: "local service", wellKnown: windows.WinLocalServiceSid}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := writeExecutable(t) + grantWrite(t, path, tt.wellKnown) + + assert.Error(t, checkOnlyOwnerWritable(path), + "write access for %s must be refused", tt.name) + }) + } +} + +// The masks are the policy: on a file any write reaches its contents, while on a +// directory only deleting or taking over an entry reaches something already +// there. Adding an entry does not, which is why the walk survives a volume root. +func TestWriteAccessMasks(t *testing.T) { + assert.NotZero(t, fileWriteAccess&windows.FILE_WRITE_DATA, "writing a file's data reaches its contents") + assert.NotZero(t, fileWriteAccess&windows.FILE_APPEND_DATA, "appending to a file reaches its contents") + + assert.Zero(t, dirWriteAccess&windows.FILE_WRITE_DATA, "adding a file to a directory replaces nothing") + assert.Zero(t, dirWriteAccess&windows.FILE_APPEND_DATA, "adding a subdirectory replaces nothing") + assert.NotZero(t, dirWriteAccess&fileDeleteChild, "deleting an entry replaces it") + assert.NotZero(t, dirWriteAccess&windows.DELETE, "deleting the directory takes its entries with it") +} + +func TestIsAllowACE(t *testing.T) { + tests := []struct { + name string + aceType uint8 + want bool + }{ + {name: "allowed", aceType: windows.ACCESS_ALLOWED_ACE_TYPE, want: true}, + {name: "allowed callback", aceType: accessAllowedCallbackACEType, want: true}, + {name: "allowed object", aceType: accessAllowedObjectACEType, want: true}, + {name: "allowed callback object", aceType: accessAllowedCallbackObjectACEType, want: true}, + {name: "denied", aceType: windows.ACCESS_DENIED_ACE_TYPE}, + // SYSTEM_AUDIT_ACE_TYPE, which x/sys does not define: an ACE that records + // access rather than granting it. + {name: "audit", aceType: 0x2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isAllowACE(tt.aceType), "ACE type %#x", tt.aceType) + }) + } +} + +// writeExecutable creates a plain file under the test's own directory, the shape +// trustedSelf checks. +func writeExecutable(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "netbird-ui.exe") + require.NoError(t, os.WriteFile(path, []byte("MZ"), 0o755), "write the executable") + return path +} + +// grantWrite replaces the file's DACL with one that grants a well-known trustee +// everything, keeping the test user's own access so the file stays deletable. +func grantWrite(t *testing.T, path string, wellKnown windows.WELL_KNOWN_SID_TYPE) { + t.Helper() + + trustee, err := windows.CreateWellKnownSid(wellKnown) + require.NoError(t, err, "build the trustee SID") + self, err := currentUserSID() + require.NoError(t, err, "read the test user's SID") + + acl, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{ + fullControl(self, windows.TRUSTEE_IS_USER), + fullControl(trustee, windows.TRUSTEE_IS_WELL_KNOWN_GROUP), + }, nil) + require.NoError(t, err, "build the ACL") + + require.NoError(t, windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, + nil, nil, acl, nil), "set the DACL") +} + +func fullControl(sid *windows.SID, trusteeType uint32) windows.EXPLICIT_ACCESS { + return windows.EXPLICIT_ACCESS{ + AccessPermissions: windows.GENERIC_ALL, + AccessMode: windows.GRANT_ACCESS, + Trustee: windows.TRUSTEE{ + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: windows.TRUSTEE_TYPE(trusteeType), + TrusteeValue: windows.TrusteeValueFromSID(sid), + }, + } +} diff --git a/client/internal/engine.go b/client/internal/engine.go index 1d00ed0d2..d517d1d68 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -14,15 +14,18 @@ import ( "sort" "strings" "sync" + "sync/atomic" "time" "github.com/hashicorp/go-multierror" "github.com/pion/ice/v4" "github.com/pion/stun/v3" log "github.com/sirupsen/logrus" + wgdevice "golang.zx2c4.com/wireguard/device" "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" @@ -58,13 +61,17 @@ 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/netevents" cProto "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/system" nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/route" mgm "github.com/netbirdio/netbird/shared/management/client" "github.com/netbirdio/netbird/shared/management/domain" + sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc" + nbnetworkmap "github.com/netbirdio/netbird/shared/management/networkmap" mgmProto "github.com/netbirdio/netbird/shared/management/proto" + types "github.com/netbirdio/netbird/shared/management/types" "github.com/netbirdio/netbird/shared/netiputil" auth "github.com/netbirdio/netbird/shared/relay/auth/hmac" relayClient "github.com/netbirdio/netbird/shared/relay/client" @@ -89,6 +96,13 @@ const ( // exec, os.Stat); without this bound a single stuck call freezes handleSync, and // thus syncMsgMux, for as long as the call hangs (observed multi-minute freezes). systemInfoTimeout = 15 * time.Second + + // dnsForwarderStopTimeout bounds how long stopping the DNS forwarder waits + // for the queries still in flight. One waiting on an unresponsive upstream + // would otherwise hold the stop for the whole upstream timeout, and the + // stop runs with syncMsgMux held. The sockets are closed either way, so + // giving up costs a query that was already failing. + dnsForwarderStopTimeout = 2 * time.Second ) var ErrResetConnection = fmt.Errorf("reset connection") @@ -132,6 +146,7 @@ type EngineConfig struct { RosenpassPermissive bool ServerSSHAllowed bool + RemoteJobsAllowed bool EnableSSHRoot *bool EnableSSHSFTP *bool EnableSSHLocalPortForwarding *bool @@ -147,6 +162,7 @@ type EngineConfig struct { BlockLANAccess bool BlockInbound bool DisableIPv6 bool + SyncMessageVersion *int // LazyConnection is the MDM-sourced lazy-connection override; StateUnset defers to // the env var and management feature flag. @@ -176,6 +192,9 @@ type EngineServices struct { UpdateManager *updater.Manager ClientMetrics *metrics.ClientMetrics MetricsCtx context.Context + // NetMgr gates the reconnection loops on OS-reported network + // availability; nil disables gating. + NetMgr *netevents.Manager } // Engine is a mechanism responsible for reacting on Signal and Management stream events and managing connections to the remote peers. @@ -199,6 +218,10 @@ type Engine struct { config *EngineConfig mobileDep MobileDependency + // netMgr gates the peer reconnection guards on OS-reported network + // availability; nil disables gating. + netMgr *netevents.Manager + // STUNs is a list of STUN servers used by ICE STUNs []*stun.URI // TURNs is a list of STUN servers used by ICE @@ -215,11 +238,24 @@ type Engine struct { wgInterface WGIface + // wgDevice is a lock-free handle on the WireGuard device behind + // wgInterface. Reaching the device through wgInterface requires + // syncMsgMux, which handleSync holds while it adds and removes peers; + // SetPerformance must stay reachable exactly when that work is stuck. + wgDevice atomic.Pointer[wgdevice.Device] + udpMux *udpmux.UniversalUDPMuxDefault // networkSerial is the latest CurrentSerial (state ID) of the network sent by the Management service networkSerial uint64 + // latestComponents is the most-recent NetworkMapComponents decoded from + // a NetworkMapEnvelope (capability=3 peers only). Held alongside the + // NetworkMap that Calculate() produced from it so future incremental + // updates have a base to apply changes against. nil for legacy-format + // peers. Guarded by syncMsgMux. + latestComponents *types.NetworkMapComponents + networkMonitor *networkmonitor.NetworkMonitor sshServer sshServer @@ -237,6 +273,8 @@ type Engine struct { // checks are the client-applied posture checks that need to be evaluated on the client checks []*mgmProto.Checks + infoSource system.InfoSource + relayManager *relayClient.Manager stateManager *statemanager.Manager portForwardManager *portforward.Manager @@ -300,6 +338,10 @@ type localIpUpdater interface { UpdateLocalIPs() error } +// overlayRebind rebuilds one subsystem's sockets on the current interface. The +// error it returns names its own subsystem, since the caller can only log it. +type overlayRebind func() error + // NewEngine creates a new Connection Engine with probes attached func NewEngine( clientCtx context.Context, @@ -325,6 +367,7 @@ func NewEngine( syncMsgMux: &sync.Mutex{}, config: config, mobileDep: mobileDep, + netMgr: services.NetMgr, STUNs: []*stun.URI{}, TURNs: []*stun.URI{}, networkSerial: 0, @@ -561,12 +604,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) } @@ -584,10 +622,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, @@ -623,6 +659,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) log.Errorf("failed to pull up wgInterface [%s]: %s", e.wgInterface.Name(), err.Error()) return fmt.Errorf("up wg interface: %w", err) } + e.wgDevice.Store(e.wgInterface.GetWGDevice()) // Set up notrack rules immediately after proxy is listening to prevent // conntrack entries from being created before the rules are in place @@ -652,8 +689,24 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) iceCfg := e.createICEConfig() e.connMgr = NewConnMgr(e.config, e.statusRecorder, e.peerStore, wgIface) + e.connMgr.SetRoutedIPsReconciler(func(peerKey string) error { + if e.routeManager == nil { + return nil + } + return e.routeManager.ReconcilePeerAllowedIPs(peerKey) + }) e.connMgr.Start(e.ctx) + // Wire DNS-time lazy-connection warm-up now that the connection manager + // exists (it does not at DNS-server construction time). A DNS answer that + // points at an idle peer then wakes it before the client's first request. + e.dnsServer.SetPeerActivator(&dnsPeerActivator{ + connMgr: e.connMgr, + peerStore: e.peerStore, + status: e.statusRecorder, + ctx: e.ctx, + }) + e.srWatcher = guard.NewSRWatcher(e.signal, e.relayManager, e.mobileDep.IFaceDiscover, iceCfg) e.srWatcher.Start(peer.IsForceRelayed()) @@ -714,6 +767,11 @@ func (e *Engine) initFirewall() error { return fmt.Errorf("set firewall: %w", err) } + // TODO: the firewall backends dedup filter rules by content, so a + // management route ACL with identical content would collapse onto the + // untracked drop rules installed here, and a later management delete + // could remove them. Needs backend refcounting or per-consumer key + // namespacing. if e.config.BlockLANAccess { e.blockLanAccess() } @@ -726,14 +784,14 @@ func (e *Engine) initFirewall() error { port := firewallManager.Port{Values: []uint16{uint16(rosenpassPort)}} // IPv4-only: rosenpass peers connect via AllowedIps[0] which is always v4. - if _, err := e.firewall.AddPeerFiltering( + if _, err := e.firewall.AddFilterRule( nil, - net.IP{0, 0, 0, 0}, + []netip.Prefix{netip.PrefixFrom(netip.IPv4Unspecified(), 0)}, + firewallManager.Network{}, firewallManager.ProtocolUDP, nil, &port, firewallManager.ActionAccept, - "", ); err != nil { log.Errorf("failed to allow rosenpass interface traffic: %v", err) return nil @@ -783,7 +841,7 @@ func (e *Engine) blockLanAccess() { if network.Addr().Is6() { source = v6 } - if _, err := e.firewall.AddRouteFiltering( + if _, err := e.firewall.AddFilterRule( nil, []netip.Prefix{source}, firewallManager.Network{Prefix: network}, @@ -842,8 +900,7 @@ func (e *Engine) modifyPeers(peersUpdate []*mgmProto.RemotePeerConfig) error { } // third, add the peer connections again for _, p := range modified { - err := e.addNewPeer(p) - if err != nil { + if err := e.addNewPeer(p); err != nil { return err } } @@ -963,8 +1020,12 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { e.ApplySessionDeadline(update.GetSessionExpiresAt()) - if update.NetworkMap != nil && update.NetworkMap.PeerConfig != nil { - e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate) + // Envelope sync responses carry PeerConfig at the top level; legacy + // NetworkMap syncs carry it under NetworkMap.PeerConfig. + if pc := update.GetPeerConfig(); pc != nil { + e.handleAutoUpdateVersion(pc.GetAutoUpdate()) + } else if nm := update.GetNetworkMap(); nm != nil && nm.GetPeerConfig() != nil { + e.handleAutoUpdateVersion(nm.GetPeerConfig().GetAutoUpdate()) } done := e.phase("netbird_config") @@ -974,12 +1035,47 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { return err } + // Decode the network map from either the components envelope or the + // legacy proto.NetworkMap before the posture-check gating below, so the + // "is there a network map" decision covers both wire shapes. + var ( + nm *mgmProto.NetworkMap + components *types.NetworkMapComponents + ) + if version := update.GetVersion(); version == int32(sharedgrpc.ComponentNetworkMap) { + // Components-format peer: decode the envelope back to typed + // components, run Calculate() locally, and convert to the wire + // NetworkMap shape the rest of the engine consumes. Components are + // retained so future incremental updates can apply deltas instead + // of doing a full reconstruction. + envelope := update.GetNetworkMapEnvelope() + if envelope == nil { + return fmt.Errorf("received a SyncReponse indicating use of components network map, but components are missing") + } + + localKey := e.config.WgPrivateKey.PublicKey().String() + dnsName := "" + if pc := update.GetPeerConfig(); pc != nil { + // PeerConfig.Fqdn = "." — extract the + // shared domain by stripping the peer's own label prefix. Falls + // back to empty if the FQDN doesn't have the expected shape. + dnsName = extractDNSDomainFromFQDN(pc.GetFqdn()) + } + result, err := nbnetworkmap.EnvelopeToNetworkMap(e.ctx, envelope, localKey, dnsName) + if err != nil { + return fmt.Errorf("decode network map envelope: %w", err) + } + nm = result.NetworkMap + components = result.Components + } else { + nm = update.GetNetworkMap() + } + // Posture checks are bound to the network map presence: // NetworkMap != nil, checks present -> apply the received checks // NetworkMap != nil, checks nil -> posture checks were removed, clear them // NetworkMap == nil -> config-only update (e.g. relay token rotation), // leave the previously applied checks untouched - nm := update.GetNetworkMap() if nm == nil { return nil } @@ -992,6 +1088,14 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { } done = e.phase("persist") + // Only retain the components view when the server sent the envelope + // path. A legacy proto.NetworkMap means components == nil; writing it + // here would clobber a previously-cached snapshot, breaking the + // incremental-delta base on a future envelope sync. + if components != nil { + e.latestComponents = components + } + e.persistSyncResponse(update) done() @@ -1005,6 +1109,19 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { return nil } +// extractDNSDomainFromFQDN returns the trailing dotted domain part of the +// receiving peer's FQDN — the same value the management server fills as +// dnsName when it builds the legacy NetworkMap. "peer42.netbird.cloud" → +// "netbird.cloud". An empty string is returned for unrecognized formats. +func extractDNSDomainFromFQDN(fqdn string) string { + for i := 0; i < len(fqdn); i++ { + if fqdn[i] == '.' && i+1 < len(fqdn) { + return fqdn[i+1:] + } + } + return "" +} + // updateNetbirdConfig applies the management-provided NetBird configuration: // STUN/TURN and relay servers, flow logging and DNS settings. A nil config is a no-op, // which is the case for sync updates carrying only a network map. @@ -1135,9 +1252,7 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error { if isChecksEqual(e.checks, checks) { return nil } - e.checks = checks - - info, ok := system.GetInfoWithChecksTimeout(e.ctx, systemInfoTimeout, checks, e.overlayAddresses()...) + info, ok := e.infoSource.Refresh(e.ctx, systemInfoTimeout, checks, e.overlayAddresses()...) if !ok { // Gathering timed out; skip the meta sync this cycle rather than blocking the // sync loop (and syncMsgMux) on a stuck system call. A later sync will retry. @@ -1148,6 +1263,7 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error { if err := e.mgmClient.SyncMeta(info); err != nil { return fmt.Errorf("could not sync meta: error %s", err) } + e.checks = checks return nil } @@ -1164,14 +1280,38 @@ func (e *Engine) applyInfoFlags(info *system.Info) { 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, + &e.config.RemoteJobsAllowed, ) } +func (e *Engine) currentSystemInfo(ctx context.Context) *system.Info { + info := e.infoSource.Current(ctx, e.overlayAddresses()...) + e.applyInfoFlags(info) + return info +} + +// syncInfoFunc returns the info callback for the management sync stream. The +// first connect sends the info refreshed right before it instead of gathering +// again; every reconnect gathers a fresh one. The stream retry loop calls the +// callback sequentially, so the handoff needs no synchronization. +func (e *Engine) syncInfoFunc(refreshed *system.Info) func(ctx context.Context) *system.Info { + return func(ctx context.Context) *system.Info { + if refreshed == nil { + return e.currentSystemInfo(ctx) + } + info := refreshed + refreshed = nil + e.applyInfoFlags(info) + return info + } +} + // overlayAddresses returns our own WireGuard overlay address (v4 and v6) so it // can be excluded from the reported network addresses; the interface coming and // going otherwise churns the peer meta on the management server. @@ -1254,6 +1394,13 @@ func (e *Engine) receiveJobEvents() { ID: msg.ID, Status: mgmProto.JobStatus_failed, } + // Remote jobs are an explicit opt-in. When not enabled on this + // peer, every job is refused before any work is done. + if !e.config.RemoteJobsAllowed { + log.Warnf("refusing remote job: remote jobs are not enabled on this peer (enable with --allow-remote-jobs)") + resp.Reason = []byte("remote jobs are not enabled on this peer") + return &resp + } switch params := msg.WorkloadParameters.(type) { case *mgmProto.JobRequest_Bundle: bundleResult, err := e.handleBundle(params.Bundle) @@ -1283,7 +1430,25 @@ func (e *Engine) receiveJobEvents() { } func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobResponse_Bundle, error) { - log.Infof("handle remote debug bundle request: %s", params.String()) + // The upload URL can carry a host, credentials, or query tokens, so it is + // kept out of the info-level line; the full parameters stay available at + // debug level for troubleshooting. + log.Infof("handle remote debug bundle request: anonymize=%v anonymize_level=%q log_file_count=%d bundle_for=%v bundle_for_time=%d", + params.GetAnonymize(), params.GetAnonymizeLevel(), params.GetLogFileCount(), params.GetBundleFor(), params.GetBundleForTime()) + log.Debugf("remote debug bundle request parameters: %s", params.String()) + + // Resolve the upload destination: an MDM override, when set, takes + // precedence over the management-supplied URL. Both are validated the same + // way; an empty result falls back to the default upload server downstream. + uploadURL := params.GetUploadUrl() + if override := e.config.ProfileConfig.DebugBundleUploadURL; override != "" { + log.Infof("using MDM debug bundle upload URL override instead of the management-supplied value") + uploadURL = override + } + if err := validateBundleUploadURL(uploadURL); err != nil { + return nil, err + } + syncResponse, err := e.GetLatestSyncResponse() if err != nil { log.Warnf("get latest sync response: %v", err) @@ -1304,13 +1469,14 @@ 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), } waitFor := time.Duration(params.BundleForTime) * time.Minute - uploadKey, err := e.jobExecutor.BundleJob(e.ctx, bundleDeps, bundleJobParams, waitFor, e.config.ProfileConfig.ManagementURL.String()) + uploadKey, err := e.jobExecutor.BundleJob(e.ctx, bundleDeps, bundleJobParams, waitFor, e.config.ProfileConfig.ManagementURL.String(), uploadURL) if err != nil { return nil, err } @@ -1323,21 +1489,27 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR return response, nil } +// validateBundleUploadURL sanity-checks a management-supplied upload URL for a +// remote debug bundle job. It delegates to profilemanager.ValidateBundleUploadURL +// so the executor and the MDM policy override share one definition of the rule +// (empty accepted; otherwise a well-formed https URL with a host) and cannot +// drift. The host is deliberately left unconstrained pending a decision on +// management-directed uploads. +func validateBundleUploadURL(raw string) error { + return profilemanager.ValidateBundleUploadURL(raw) +} + // receiveManagementEvents connects to the Management Service event stream to receive updates from the management service // E.g. when a new peer has been registered and we are allowed to connect to it. func (e *Engine) receiveManagementEvents() { e.shutdownWg.Add(1) go func() { defer e.shutdownWg.Done() - info, ok := system.GetInfoWithChecksTimeout(e.ctx, systemInfoTimeout, e.checks, e.overlayAddresses()...) + info, ok := e.infoSource.Refresh(e.ctx, systemInfoTimeout, e.checks, e.overlayAddresses()...) if !ok { - // Gathering timed out; connect the stream with base info so management - // connectivity still comes up rather than blocking here. - info = system.GetInfo(e.ctx) + log.Warnf("posture checks not refreshed before the sync connect, sending the previous results") } - e.applyInfoFlags(info) - - err := e.mgmClient.Sync(e.ctx, info, e.handleSync) + err := e.mgmClient.Sync(e.ctx, e.syncInfoFunc(info), e.handleSync) if err != nil { // happens if management is unavailable for a long time. // We want to cancel the operation of the whole client @@ -1403,8 +1575,12 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error { return nil } - if err := e.connMgr.UpdatedRemoteFeatureFlag(e.ctx, networkMap.GetPeerConfig().GetLazyConnectionEnabled()); err != nil { - log.Errorf("failed to update lazy connection feature flag: %v", err) + // Only update the flag when the sync carries a peer config; a nil peer config + // (e.g. a partial update) must not reset the cached flag to false. + if peerConfig := networkMap.GetPeerConfig(); peerConfig != nil { + if err := e.connMgr.UpdatedRemoteFeatureFlag(e.ctx, peerConfig.GetLazyConnectionEnabled()); err != nil { + log.Errorf("failed to update lazy connection feature flag: %v", err) + } } if e.firewall != nil { @@ -1470,8 +1646,7 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error { // Ingress forward rules done = e.phase("forward_rules") - forwardingRules, err := e.updateForwardRules(networkMap.GetForwardingRules()) - if err != nil { + if _, err := e.updateForwardRules(networkMap.GetForwardingRules()); err != nil { log.Errorf("failed to update forward rules, err: %v", err) } done() @@ -1489,8 +1664,7 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error { // must set the exclude list after the peers are added. Without it the manager can not figure out the peers parameters from the store done = e.phase("lazy_exclude") - excludedLazyPeers := e.toExcludedLazyPeers(forwardingRules, remotePeers) - e.connMgr.SetExcludeList(e.ctx, excludedLazyPeers) + e.connMgr.SetExcludeList(e.ctx, e.toExcludedLazyPeers(remotePeers)) done() e.networkSerial = serial @@ -1734,15 +1908,15 @@ func addrToString(addr netip.Addr) string { // addNewPeers adds peers that were not know before but arrived from the Management service with the update func (e *Engine) addNewPeers(peersUpdate []*mgmProto.RemotePeerConfig) error { for _, p := range peersUpdate { - err := e.addNewPeer(p) - if err != nil { + if err := e.addNewPeer(p); err != nil { return err } } return nil } -// addNewPeer add peer if connection doesn't exist +// addNewPeer add peer if connection doesn't exist. A peer that is not lazy by +// policy gets an always-active connection instead. func (e *Engine) addNewPeer(peerConfig *mgmProto.RemotePeerConfig) error { peerKey := peerConfig.GetWgPubKey() peerIPs := make([]netip.Prefix, 0, len(peerConfig.GetAllowedIps())) @@ -1777,7 +1951,8 @@ func (e *Engine) addNewPeer(peerConfig *mgmProto.RemotePeerConfig) error { log.Warnf("error adding peer %s to status recorder, got error: %v", peerKey, err) } - if exists := e.connMgr.AddPeerConn(e.ctx, peerKey, conn); exists { + permanent := !e.connMgr.PeerLazyDefault(peerConfig.GetLazyState()) + if exists := e.connMgr.AddPeerConn(e.ctx, peerKey, conn, permanent); exists { conn.Close(false) return fmt.Errorf("peer already exists: %s", peerKey) } @@ -1811,6 +1986,7 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV PermissiveMode: e.config.RosenpassPermissive, }, ICEConfig: e.createICEConfig(), + NetMgr: e.netMgr, } serviceDependencies := peer.ServiceDependencies{ @@ -1977,6 +2153,10 @@ func (e *Engine) close() { log.Debugf("removing Netbird interface %s", e.config.WgIfaceName) if e.wgInterface != nil { + // Drop the handle before the close starts: a retune that loads it + // afterwards would touch a device on its way out and report success + // for an engine that is already gone. + e.wgDevice.Store(nil) if err := e.wgInterface.Close(); err != nil { log.Errorf("failed closing Netbird interface %s %v", e.config.WgIfaceName, err) } @@ -2014,41 +2194,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.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 { @@ -2083,7 +2228,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() { @@ -2096,7 +2241,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 @@ -2108,7 +2253,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, @@ -2171,15 +2316,16 @@ type Performance struct { } // SetPerformance applies the given tuning to this engine's live Device. +// +// It deliberately does not take syncMsgMux. Raising the buffer pool cap is the +// recovery path for a device whose pool is exhausted, and an exhausted pool +// blocks peer removal inside handleSync, which holds syncMsgMux for as long as +// it stays blocked. Taking the lock here would make the retune unreachable in +// the one situation that needs it. func (e *Engine) SetPerformance(t Performance) error { - e.syncMsgMux.Lock() - defer e.syncMsgMux.Unlock() - if e.wgInterface == nil { - return fmt.Errorf("wg interface not initialized") - } - dev := e.wgInterface.GetWGDevice() + dev := e.wgDevice.Load() if dev == nil { - return fmt.Errorf("wg device not initialized") + return errors.New("wg device not initialized") } if t.PreallocatedBuffersPerPool != nil { dev.SetPreallocatedBuffersPerPool(*t.PreallocatedBuffersPerPool) @@ -2400,7 +2546,72 @@ 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.rebindOverlayListeners() + return nil +} + +// rebindOverlayListeners gives the servers that listen on an overlay address +// sockets on the interface as it is now. +// +// A socket belongs to the interface generation it was created on. Renewing the +// TUN builds a new interface and moves the overlay addresses to it, which +// leaves the old sockets in LISTEN with the uspfilter still logging packets +// arriving for them, while every accept fails with EINVAL for the life of the +// socket: from the outside the server looks alive and answers nothing. On +// Android this happens during a normal startup, where the first TUN is +// established before the routes are known and replaced once they arrive. +// +// Rebinding costs whatever those sockets were carrying, which the renewal has +// already broken. Errors are logged rather than returned: the renewal itself +// succeeded, and failing it would hand the caller a working interface and an +// error. +func (e *Engine) rebindOverlayListeners() { + e.syncMsgMux.Lock() + defer e.syncMsgMux.Unlock() + + for _, rebind := range e.overlayRebinds() { + if err := rebind(); err != nil { + log.Errorf("after TUN renewal: %v", err) + } + } +} + +// overlayRebinds is every subsystem of this engine that holds sockets bound to +// an overlay address, and how to rebuild each one's. +// +// A subsystem that starts listening on an overlay address belongs in this list. +// Leaving it out costs nothing that review would notice and produces a listener +// that stays in LISTEN, is logged as receiving packets, and refuses every +// connection for the life of the process. +func (e *Engine) overlayRebinds() []overlayRebind { + return []overlayRebind{ + e.restartSSHListeners, + e.restartDNSForwarder, + } +} + +// restartDNSForwarder rebuilds the DNS forwarder serving the same domains. +// No-op when it is not running. See Engine.rebindOverlayListeners. +func (e *Engine) restartDNSForwarder() error { + if e.dnsForwardMgr == nil { + return nil + } + // Read from the forwarder before it goes away, so the replacement serves + // the domains in force now rather than a copy kept somewhere else. + entries := e.dnsForwardMgr.Domains() + e.stopDNSForwarder() + // Both halves log their own failures, so the only thing left to report is + // the outcome: a start that failed left the manager nil, and the forwarder + // is now down rather than merely rebound. + e.startDNSForwarder(entries) + if e.dnsForwardMgr == nil { + return errors.New("rebind DNS forwarder: it did not come back up") + } + return nil } // updateDNSForwarder start or stop the DNS forwarder based on the domains and the feature flag @@ -2446,7 +2657,14 @@ func (e *Engine) stopDNSForwarder() { return } - if err := e.dnsForwardMgr.Stop(context.Background()); err != nil { + // Bounded because the shutdown waits for queries still in flight, and one + // waiting on an unresponsive upstream holds it for as long as that lookup + // is allowed to take. This runs with syncMsgMux held, so that wait is one + // the whole engine spends. + ctx, cancel := context.WithTimeout(context.Background(), dnsForwarderStopTimeout) + defer cancel() + + if err := e.dnsForwardMgr.Stop(ctx); err != nil { log.Errorf("failed to stop DNS forward: %v", err) } @@ -2514,7 +2732,7 @@ func (e *Engine) SetCapture(pc device.PacketCapture) error { } afc := capture.NewAFPacketCapture(intf.Name(), sess) - if err := afc.Start(); err != nil { + if err := afc.Start(); err != nil { //nolint:staticcheck // always errors on non-Linux builds return fmt.Errorf("start AF_PACKET capture on %s: %w", intf.Name(), err) } e.afpacketCapture = afc @@ -2561,7 +2779,7 @@ func (e *Engine) updateForwardRules(rules []*mgmProto.ForwardingRule) ([]firewal var merr *multierror.Error forwardingRules := make([]firewallManager.ForwardRule, 0, len(rules)) for _, rule := range rules { - proto, err := convertToFirewallProtocol(rule.GetProtocol()) + proto, err := acl.ConvertToFirewallProtocol(rule.GetProtocol()) if err != nil { merr = multierror.Append(merr, fmt.Errorf("failed to convert protocol '%s': %w", rule.GetProtocol(), err)) continue @@ -2603,46 +2821,19 @@ func (e *Engine) updateForwardRules(rules []*mgmProto.ForwardingRule) ([]firewal return forwardingRules, nberrors.FormatErrorOrNil(merr) } -func (e *Engine) toExcludedLazyPeers(rules []firewallManager.ForwardRule, peers []*mgmProto.RemotePeerConfig) map[string]bool { +// toExcludedLazyPeers returns the peers that must have an always-active +// connection: those that are not lazy by policy (the per-peer lazy state or the +// account flag, subject to the local override). +func (e *Engine) toExcludedLazyPeers(peers []*mgmProto.RemotePeerConfig) map[string]bool { excludedPeers := make(map[string]bool) - - // Ingress forward targets: inbound forwarded traffic is initiated remotely and - // cannot wake a lazy connection, so the peer routing the target must stay - // permanently connected. AllowedIPs are already parsed on the peer conn, so - // reuse those typed prefixes instead of re-parsing the network map strings. - for _, r := range rules { - for _, p := range peers { - if e.peerRoutesAddr(p, r.TranslatedAddress) { - log.Infof("exclude forwarder peer from lazy connection: %s", p.GetWgPubKey()) - excludedPeers[p.GetWgPubKey()] = true - } + for _, p := range peers { + if !e.connMgr.PeerLazyDefault(p.GetLazyState()) { + excludedPeers[p.GetWgPubKey()] = true } } - return excludedPeers } -// peerRoutesAddr reports whether the peer is a router for addr, matched against -// the peer's already-parsed AllowedIPs from the store (the same typed value the -// lazy manager consumes) rather than re-parsing the network map strings. -func (e *Engine) peerRoutesAddr(p *mgmProto.RemotePeerConfig, addr netip.Addr) bool { - prefixes, ok := e.peerStore.AllowedIPs(p.GetWgPubKey()) - if !ok { - return false - } - return prefixesContain(prefixes, addr) -} - -// prefixesContain reports whether addr falls within any of the prefixes. -func prefixesContain(prefixes []netip.Prefix, addr netip.Addr) bool { - for _, prefix := range prefixes { - if prefix.Contains(addr) { - return true - } - } - return false -} - // isChecksEqual checks if two slices of checks are equal. func isChecksEqual(checks1, checks2 []*mgmProto.Checks) bool { normalize := func(checks []*mgmProto.Checks) []string { diff --git a/client/internal/engine_bundle_test.go b/client/internal/engine_bundle_test.go new file mode 100644 index 000000000..20b40a8a6 --- /dev/null +++ b/client/internal/engine_bundle_test.go @@ -0,0 +1,36 @@ +package internal + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestValidateBundleUploadURL covers the sanity check applied to a +// management-supplied upload URL before a remote debug bundle is generated. +func TestValidateBundleUploadURL(t *testing.T) { + for _, tc := range []struct { + name string + raw string + wantErr bool + }{ + {name: "empty falls back to default", raw: ""}, + {name: "https with host", raw: "https://upload.debug.netbird.io/upload"}, + {name: "https self-hosted host", raw: "https://upload.example.com"}, + {name: "plaintext rejected", raw: "http://upload.example.com", wantErr: true}, + {name: "missing host rejected", raw: "https:///upload", wantErr: true}, + {name: "port-only authority rejected", raw: "https://:443", wantErr: true}, + {name: "non-url scheme rejected", raw: "ftp://upload.example.com", wantErr: true}, + {name: "garbage rejected", raw: "://not a url", wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + err := validateBundleUploadURL(tc.raw) + if tc.wantErr { + require.Error(t, err, "an invalid upload URL must be rejected") + return + } + assert.NoError(t, err, "a valid or empty upload URL must be accepted") + }) + } +} diff --git a/client/internal/engine_lazy_exclude_test.go b/client/internal/engine_lazy_exclude_test.go deleted file mode 100644 index b5ef16c3b..000000000 --- a/client/internal/engine_lazy_exclude_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package internal - -import ( - "net/netip" - "testing" - - "github.com/stretchr/testify/require" - - firewallManager "github.com/netbirdio/netbird/client/firewall/manager" - "github.com/netbirdio/netbird/client/internal/peer" - "github.com/netbirdio/netbird/client/internal/peerstore" - mgmProto "github.com/netbirdio/netbird/shared/management/proto" -) - -func TestPrefixesContain(t *testing.T) { - tests := []struct { - name string - prefixes []string - addr string - want bool - }{ - {name: "own overlay /32 matches", prefixes: []string{"100.110.8.145/32"}, addr: "100.110.8.145", want: true}, - {name: "addr inside routed subnet", prefixes: []string{"10.121.0.0/16"}, addr: "10.121.208.4", want: true}, - {name: "addr outside subnet", prefixes: []string{"10.121.0.0/16"}, addr: "10.122.0.1", want: false}, - {name: "different /32", prefixes: []string{"100.110.8.145/32"}, addr: "100.110.8.146", want: false}, - {name: "ipv6 /128 matches", prefixes: []string{"fd00::1/128"}, addr: "fd00::1", want: true}, - {name: "no prefixes", prefixes: nil, addr: "10.121.208.4", want: false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - prefixes := make([]netip.Prefix, 0, len(tt.prefixes)) - for _, p := range tt.prefixes { - prefixes = append(prefixes, netip.MustParsePrefix(p)) - } - require.Equal(t, tt.want, prefixesContain(prefixes, netip.MustParseAddr(tt.addr))) - }) - } -} - -// TestToExcludedLazyPeers_ForwardTarget guards a regression: the forward-target -// peer (the peer routing a ForwardRule.TranslatedAddress) must be excluded from -// lazy connections, matched via the peer's already-parsed AllowedIPs. -func TestToExcludedLazyPeers_ForwardTarget(t *testing.T) { - const targetPeerKey = "cccccccccccccccccccccccccccccccccccccccccc0=" - const otherPeerKey = "dddddddddddddddddddddddddddddddddddddddddd0=" - - store := peerstore.NewConnStore() - store.AddPeerConn(targetPeerKey, newTestConn(t, targetPeerKey, "100.110.8.145/32")) - store.AddPeerConn(otherPeerKey, newTestConn(t, otherPeerKey, "100.110.9.10/32")) - - e := &Engine{peerStore: store} - - peers := []*mgmProto.RemotePeerConfig{ - {WgPubKey: targetPeerKey, AllowedIps: []string{"100.110.8.145/32"}}, - {WgPubKey: otherPeerKey, AllowedIps: []string{"100.110.9.10/32"}}, - } - rules := []firewallManager.ForwardRule{ - {TranslatedAddress: netip.MustParseAddr("100.110.8.145")}, - } - - excluded := e.toExcludedLazyPeers(rules, peers) - - require.True(t, excluded[targetPeerKey], "forward-target peer must be excluded from lazy connections") - require.False(t, excluded[otherPeerKey], "non-target peer must not be excluded") - require.Len(t, excluded, 1) -} - -func TestToExcludedLazyPeers_NoRules(t *testing.T) { - e := &Engine{peerStore: peerstore.NewConnStore()} - - peers := []*mgmProto.RemotePeerConfig{ - {WgPubKey: "peer-a", AllowedIps: []string{"100.110.8.145/32"}}, - } - - require.Empty(t, e.toExcludedLazyPeers(nil, peers)) -} - -func newTestConn(t *testing.T, key, allowedIP string) *peer.Conn { - t.Helper() - conn, err := peer.NewConn(peer.ConnConfig{ - Key: key, - WgConfig: peer.WgConfig{AllowedIps: []netip.Prefix{netip.MustParsePrefix(allowedIP)}}, - }, peer.ServiceDependencies{}) - require.NoError(t, err) - return conn -} diff --git a/client/internal/engine_privileged_test.go b/client/internal/engine_privileged_test.go index f787f741f..1b047e017 100644 --- a/client/internal/engine_privileged_test.go +++ b/client/internal/engine_privileged_test.go @@ -12,7 +12,7 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/google/uuid" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" @@ -193,7 +193,7 @@ func TestEngine_Sync(t *testing.T) { // feed updates to Engine via mocked Management client updates := make(chan *mgmtProto.SyncResponse) defer close(updates) - syncFunc := func(ctx context.Context, info *system.Info, msgHandler func(msg *mgmtProto.SyncResponse) error) error { + syncFunc := func(ctx context.Context, _ func(context.Context) *system.Info, msgHandler func(msg *mgmtProto.SyncResponse) error) error { for msg := range updates { err := msgHandler(msg) if err != nil { @@ -519,7 +519,7 @@ func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, stri updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := server.NewAccountRequestBuffer(context.Background(), store) - networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config) + networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config, nil) accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) if err != nil { return nil, "", err diff --git a/client/internal/engine_session_deadline_test.go b/client/internal/engine_session_deadline_test.go index 6127e5bb0..5a67f103a 100644 --- a/client/internal/engine_session_deadline_test.go +++ b/client/internal/engine_session_deadline_test.go @@ -75,4 +75,14 @@ func TestApplySessionDeadline_ThreeState(t *testing.T) { require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(), "invalid timestamp must clear the deadline") }) + + t.Run("recently expired timestamp stays visible as expired", func(t *testing.T) { + e := newEngine() + expired := time.Now().Add(-5 * time.Minute).UTC().Truncate(time.Second) + + e.ApplySessionDeadline(timestamppb.New(expired)) + + require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(expired), + "recently-expired deadline must stay on the recorder so consumers render it as expired") + }) } diff --git a/client/internal/engine_ssh.go b/client/internal/engine_ssh.go index 53d2c1122..60bdfd806 100644 --- a/client/internal/engine_ssh.go +++ b/client/internal/engine_ssh.go @@ -24,6 +24,8 @@ type sshServer interface { Stop() error GetStatus() (bool, []sshserver.SessionInfo) UpdateSSHAuth(config *sshauth.Config) + JWTConfig() *sshserver.JWTConfig + AuthConfig() *sshauth.Config } func (e *Engine) setupSSHPortRedirection() error { @@ -77,7 +79,7 @@ func (e *Engine) updateSSH(sshConf *mgmProto.SSHConfig) error { if e.config.DisableSSHAuth != nil && *e.config.DisableSSHAuth { log.Info("starting SSH server without JWT authentication (authentication disabled by config)") - return e.startSSHServer(nil) + return e.startSSHServer(nil, nil) } if protoJWT := sshConf.GetJwtConfig(); protoJWT != nil { @@ -95,7 +97,7 @@ func (e *Engine) updateSSH(sshConf *mgmProto.SSHConfig) error { MaxTokenAge: protoJWT.GetMaxTokenAge(), } - return e.startSSHServer(jwtConfig) + return e.startSSHServer(jwtConfig, nil) } return errors.New("SSH server requires valid JWT configuration") @@ -231,8 +233,33 @@ func (e *Engine) cleanupSSHConfig() { } } -// startSSHServer initializes and starts the SSH server with proper configuration. -func (e *Engine) startSSHServer(jwtConfig *sshserver.JWTConfig) error { +// restartSSHListeners rebuilds the SSH server so it listens on new sockets, on +// the same terms it was started with. No-op when it is not running. See +// Engine.rebindOverlayListeners for why this is needed. +func (e *Engine) restartSSHListeners() error { + if e.sshServer == nil { + return nil + } + // Read from the server before it goes away. A rebuilt one starts with an + // empty authorizer, which fails closed, so without carrying the + // authorization over every JWT login is refused until the next network map + // happens to bring one. + jwtConfig, authConfig := e.sshServer.JWTConfig(), e.sshServer.AuthConfig() + if err := e.stopSSHServer(); err != nil { + return fmt.Errorf("rebind SSH listeners: %w", err) + } + if err := e.startSSHServer(jwtConfig, authConfig); err != nil { + return fmt.Errorf("rebind SSH listeners: %w", err) + } + return nil +} + +// startSSHServer initializes and starts the SSH server with proper +// configuration. authConfig is the fine-grained authorization to open with, and +// is applied before the server accepts anything: a server that starts listening +// with an empty authorizer refuses the logins that arrive in the meantime. +// Nil leaves it as management has not sent one yet. +func (e *Engine) startSSHServer(jwtConfig *sshserver.JWTConfig, authConfig *sshauth.Config) error { if e.wgInterface == nil { return errors.New("wg interface not initialized") } @@ -240,6 +267,7 @@ func (e *Engine) startSSHServer(jwtConfig *sshserver.JWTConfig) error { serverConfig := &sshserver.Config{ HostKeyPEM: e.config.SSHKey, JWT: jwtConfig, + Auth: authConfig, } server := sshserver.New(serverConfig) diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go index fbd47ed74..ec388ac94 100644 --- a/client/internal/engine_test.go +++ b/client/internal/engine_test.go @@ -2,6 +2,7 @@ package internal import ( "context" + "errors" "fmt" "net" "net/netip" @@ -31,6 +32,7 @@ import ( icemaker "github.com/netbirdio/netbird/client/internal/peer/ice" "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/internal/routemanager" + "github.com/netbirdio/netbird/client/system" nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/monotime" "github.com/netbirdio/netbird/route" @@ -253,6 +255,118 @@ func TestEngine_SSHServerConsistency(t *testing.T) { }) } +func TestEngine_FirstSyncInfoCarriesLoginChecks(t *testing.T) { + key, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err) + + exe, err := os.Executable() + require.NoError(t, err) + + ctx, cancel := context.WithCancel(CtxInitState(context.Background())) + defer cancel() + + infos := make(chan *system.Info, 1) + mgmClient := &mgmt.MockClient{ + SyncFunc: func(ctx context.Context, getInfo func(context.Context) *system.Info, _ func(*mgmtProto.SyncResponse) error) error { + infos <- getInfo(ctx) + return nil + }, + } + + relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) + engine := NewEngine(ctx, cancel, &EngineConfig{ + WgIfaceName: "utun104", + WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"), + WgPrivateKey: key, + WgPort: 33100, + MTU: iface.DefaultMTU, + }, EngineServices{ + SignalClient: &signal.MockClient{}, + MgmClient: mgmClient, + RelayManager: relayMgr, + StatusRecorder: peer.NewRecorder("https://mgm"), + Checks: []*mgmtProto.Checks{{Files: []string{exe}}}, + }, MobileDependency{}) + + engine.receiveManagementEvents() + + select { + case info := <-infos: + require.Len(t, info.Files, 1) + assert.Equal(t, exe, info.Files[0].Path) + assert.True(t, info.Files[0].Exist) + case <-time.After(20 * time.Second): + t.Fatal("timeout waiting for the first sync info") + } + engine.shutdownWg.Wait() +} + +func TestEngine_SyncInfoFuncReusesRefreshedInfoOnce(t *testing.T) { + engine := &Engine{config: &EngineConfig{}} + + refreshed := &system.Info{Hostname: "from-refresh"} + getInfo := engine.syncInfoFunc(refreshed) + + first := getInfo(context.Background()) + assert.Same(t, refreshed, first, "the first connect should send the refreshed info instead of gathering again") + + second := getInfo(context.Background()) + assert.NotSame(t, refreshed, second, "the reconnect should gather a fresh info") + assert.NotEqual(t, "from-refresh", second.Hostname, "the fresh info should not carry the refreshed hostname") +} + +func TestEngine_SyncInfoFuncGathersWhenRefreshFailed(t *testing.T) { + engine := &Engine{config: &EngineConfig{}} + + info := engine.syncInfoFunc(nil)(context.Background()) + require.NotNil(t, info, "a failed refresh should fall back to gathering the info") + assert.NotEmpty(t, info.Hostname, "the gathered info should carry the hostname") +} + +func TestEngine_UpdateChecksIfNewRetriesAfterFailedSyncMeta(t *testing.T) { + key, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err) + + exe, err := os.Executable() + require.NoError(t, err) + + ctx, cancel := context.WithCancel(CtxInitState(context.Background())) + defer cancel() + + syncMetaCalls := 0 + mgmClient := &mgmt.MockClient{ + SyncMetaFunc: func(*system.Info) error { + syncMetaCalls++ + if syncMetaCalls == 1 { + return errors.New("management unavailable") + } + return nil + }, + } + + relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) + engine := NewEngine(ctx, cancel, &EngineConfig{ + WgIfaceName: "utun105", + WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"), + WgPrivateKey: key, + WgPort: 33100, + MTU: iface.DefaultMTU, + }, EngineServices{ + SignalClient: &signal.MockClient{}, + MgmClient: mgmClient, + RelayManager: relayMgr, + StatusRecorder: peer.NewRecorder("https://mgm"), + }, MobileDependency{}) + + checks := []*mgmtProto.Checks{{Files: []string{exe}}} + + require.Error(t, engine.updateChecksIfNew(checks)) + require.NoError(t, engine.updateChecksIfNew(checks)) + require.NoError(t, engine.updateChecksIfNew(checks)) + + assert.Equal(t, 2, syncMetaCalls) +} + func TestEngine_UpdateNetworkMap(t *testing.T) { // test setup key, err := wgtypes.GeneratePrivateKey() @@ -279,7 +393,8 @@ func TestEngine_UpdateNetworkMap(t *testing.T) { }, MobileDependency{}) wgIface := &MockWGIface{ - NameFunc: func() string { return "utun102" }, + NameFunc: func() string { return "utun102" }, + IsUserspaceBindFunc: func() bool { return true }, RemovePeerFunc: func(peerKey string) error { return nil }, diff --git a/client/internal/engine_tunsettings.go b/client/internal/engine_tunsettings.go new file mode 100644 index 000000000..34a59671a --- /dev/null +++ b/client/internal/engine_tunsettings.go @@ -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 +} diff --git a/client/internal/getent/cgo_unix.go b/client/internal/getent/cgo_unix.go new file mode 100644 index 000000000..2853aafff --- /dev/null +++ b/client/internal/getent/cgo_unix.go @@ -0,0 +1,36 @@ +//go:build cgo && !osusergo && !windows + +package getent + +import "os/user" + +// Built with cgo, os/user resolves through libc (getpwnam_r and friends), +// which goes through the host's NSS stack natively. Whatever it fails to +// find, the getent command would not find either, so there is nothing to +// fall back to. + +// LookupUser looks up a user by name. +func LookupUser(username string) (*user.User, error) { + return user.Lookup(username) +} + +// LookupUserID looks up a user by UID. +func LookupUserID(uid string) (*user.User, error) { + return user.LookupId(uid) +} + +// CurrentUser returns the user this process runs as. +func CurrentUser() (*user.User, error) { + return user.Current() +} + +// LookupGroupID looks up a group by GID. +func LookupGroupID(gid string) (*user.Group, error) { + return user.LookupGroupId(gid) +} + +// GroupIDs returns the IDs of the groups the user is a member of; libc's +// getgrouplist handles NSS groups natively. +func GroupIDs(u *user.User) ([]string, error) { + return u.GroupIds() +} diff --git a/client/internal/getent/getent.go b/client/internal/getent/getent.go new file mode 100644 index 000000000..9cfebe64b --- /dev/null +++ b/client/internal/getent/getent.go @@ -0,0 +1,6 @@ +// Package getent resolves users and groups through the host's NSS stack. +// Built without cgo, os/user reads /etc/passwd and /etc/group alone and misses +// anything LDAP, SSSD or winbind provide; the getent and id commands resolve +// through NSS whatever the build. The lookups here try the standard library +// first, which needs no subprocess, and fall back to those commands. +package getent diff --git a/client/ssh/server/getent_test.go b/client/internal/getent/getent_test.go similarity index 53% rename from client/ssh/server/getent_test.go rename to client/internal/getent/getent_test.go index 5eac2fdbe..8176eba36 100644 --- a/client/ssh/server/getent_test.go +++ b/client/internal/getent/getent_test.go @@ -1,4 +1,4 @@ -package server +package getent import ( "os/user" @@ -10,38 +10,48 @@ import ( "github.com/stretchr/testify/require" ) -func TestLookupWithGetent_CurrentUser(t *testing.T) { +func TestLookupUser_CurrentUser(t *testing.T) { // The current user should always be resolvable on any platform current, err := user.Current() require.NoError(t, err) - u, err := lookupWithGetent(current.Username) + u, err := LookupUser(current.Username) require.NoError(t, err) assert.Equal(t, current.Username, u.Username) assert.Equal(t, current.Uid, u.Uid) assert.Equal(t, current.Gid, u.Gid) } -func TestLookupWithGetent_NonexistentUser(t *testing.T) { - _, err := lookupWithGetent("nonexistent_user_xyzzy_12345") +func TestLookupUser_NonexistentUser(t *testing.T) { + _, err := LookupUser("nonexistent_user_xyzzy_12345") require.Error(t, err, "should fail for nonexistent user") } -func TestCurrentUserWithGetent(t *testing.T) { +func TestLookupUserID_CurrentUser(t *testing.T) { + current, err := user.Current() + require.NoError(t, err) + + u, err := LookupUserID(current.Uid) + require.NoError(t, err) + assert.Equal(t, current.Username, u.Username) + assert.Equal(t, current.Uid, u.Uid) +} + +func TestCurrentUser(t *testing.T) { stdUser, err := user.Current() require.NoError(t, err) - u, err := currentUserWithGetent() + u, err := CurrentUser() require.NoError(t, err) assert.Equal(t, stdUser.Uid, u.Uid) assert.Equal(t, stdUser.Username, u.Username) } -func TestGroupIdsWithFallback_CurrentUser(t *testing.T) { +func TestGroupIDs_CurrentUser(t *testing.T) { current, err := user.Current() require.NoError(t, err) - groups, err := groupIdsWithFallback(current) + groups, err := GroupIDs(current) require.NoError(t, err) require.NotEmpty(t, groups, "current user should have at least one group") @@ -53,32 +63,30 @@ func TestGroupIdsWithFallback_CurrentUser(t *testing.T) { } } -func TestGetShellFromGetent_CurrentUser(t *testing.T) { - if runtime.GOOS == "windows" { - // Windows stub always returns empty, which is correct - shell := getShellFromGetent("1000") - assert.Empty(t, shell, "Windows stub should return empty") - return - } - +func TestUserShell_CurrentUser(t *testing.T) { current, err := user.Current() require.NoError(t, err) - // getent may not be available on all systems (e.g., macOS without Homebrew getent) - shell := getShellFromGetent(current.Uid) + // getent may not be available on all systems (e.g., macOS without + // Homebrew getent), and Windows has no login shells at all. + shell, err := UserShell(current.Uid) + if err != nil { + t.Logf("UserShell failed, getent may not be available: %v", err) + return + } if shell == "" { - t.Log("getShellFromGetent returned empty, getent may not be available") + t.Log("UserShell returned empty, the user has no shell set") return } assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell) } -func TestLookupWithGetent_RootUser(t *testing.T) { +func TestLookupUser_RootUser(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("no root user on Windows") } - u, err := lookupWithGetent("root") + u, err := LookupUser("root") if err != nil { t.Skip("root user not available on this system") } @@ -86,25 +94,25 @@ func TestLookupWithGetent_RootUser(t *testing.T) { } // TestIntegration_FullLookupChain exercises the complete user lookup chain -// against the real system, testing that all wrappers (lookupWithGetent, -// currentUserWithGetent, groupIdsWithFallback, getShellFromGetent) produce -// consistent and correct results when composed together. +// against the real system, testing that all wrappers (LookupUser, +// CurrentUser, GroupIDs, UserShell) produce consistent and correct results +// when composed together. func TestIntegration_FullLookupChain(t *testing.T) { - // Step 1: currentUserWithGetent must resolve the running user. - current, err := currentUserWithGetent() - require.NoError(t, err, "currentUserWithGetent must resolve the running user") + // Step 1: CurrentUser must resolve the running user. + current, err := CurrentUser() + require.NoError(t, err, "CurrentUser must resolve the running user") require.NotEmpty(t, current.Uid) require.NotEmpty(t, current.Username) - // Step 2: lookupWithGetent by the same username must return matching identity. - byName, err := lookupWithGetent(current.Username) + // Step 2: LookupUser by the same username must return matching identity. + byName, err := LookupUser(current.Username) require.NoError(t, err) assert.Equal(t, current.Uid, byName.Uid, "lookup by name should return same UID") assert.Equal(t, current.Gid, byName.Gid, "lookup by name should return same GID") assert.Equal(t, current.HomeDir, byName.HomeDir, "lookup by name should return same home") - // Step 3: groupIdsWithFallback must return at least the primary GID. - groups, err := groupIdsWithFallback(current) + // Step 3: GroupIDs must return at least the primary GID. + groups, err := GroupIDs(current) require.NoError(t, err) require.NotEmpty(t, groups, "user must have at least one group") @@ -119,29 +127,20 @@ func TestIntegration_FullLookupChain(t *testing.T) { } } assert.True(t, foundPrimary, "primary GID %s should appear in supplementary groups", current.Gid) - - // Step 4: getShellFromGetent should either return a valid shell path or empty - // (empty is OK when getent is not available, e.g. macOS without Homebrew getent). - if runtime.GOOS != "windows" { - shell := getShellFromGetent(current.Uid) - if shell != "" { - assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell) - } - } } // TestIntegration_LookupAndGroupsConsistency verifies that a user resolved via -// lookupWithGetent can have their groups resolved via groupIdsWithFallback, -// testing the handoff between the two functions as used by the SSH server. +// LookupUser can have their groups resolved via GroupIDs, testing the handoff +// between the two functions as used by the SSH server. func TestIntegration_LookupAndGroupsConsistency(t *testing.T) { current, err := user.Current() require.NoError(t, err) // Simulate the SSH server flow: lookup user, then get their groups. - resolved, err := lookupWithGetent(current.Username) + resolved, err := LookupUser(current.Username) require.NoError(t, err) - groups, err := groupIdsWithFallback(resolved) + groups, err := GroupIDs(resolved) require.NoError(t, err) require.NotEmpty(t, groups, "resolved user must have groups") @@ -154,19 +153,3 @@ func TestIntegration_LookupAndGroupsConsistency(t *testing.T) { } } } - -// TestIntegration_ShellLookupChain tests the full shell resolution chain -// (getShellFromPasswd -> getShellFromGetent -> $SHELL -> default) on Unix. -func TestIntegration_ShellLookupChain(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("Unix shell lookup not applicable on Windows") - } - - current, err := user.Current() - require.NoError(t, err) - - // getUserShell is the top-level function used by the SSH server. - shell := getUserShell(current.Uid) - require.NotEmpty(t, shell, "getUserShell must always return a shell") - assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell) -} diff --git a/client/internal/getent/nocgo_unix.go b/client/internal/getent/nocgo_unix.go new file mode 100644 index 000000000..94d8ea6a9 --- /dev/null +++ b/client/internal/getent/nocgo_unix.go @@ -0,0 +1,110 @@ +//go:build (!cgo || osusergo) && !windows + +package getent + +import ( + "os" + "os/user" + "strconv" + + log "github.com/sirupsen/logrus" +) + +// Without cgo, os/user only reads /etc/passwd and /etc/group and misses +// NSS-provided users and groups; the getent and id commands go through the +// host's NSS stack. + +// LookupUser looks up a user by name, falling back to getent if os/user fails. +func LookupUser(username string) (*user.User, error) { + u, err := user.Lookup(username) + if err == nil { + return u, nil + } + + stdErr := err + log.Debugf("os/user.Lookup(%q) failed, trying getent: %v", username, err) + + u, _, getentErr := passwdLookup(username) + if getentErr != nil { + log.Debugf("getent fallback for %q also failed: %v", username, getentErr) + return nil, stdErr + } + return u, nil +} + +// LookupUserID looks up a user by UID, falling back to getent if os/user fails. +func LookupUserID(uid string) (*user.User, error) { + u, err := user.LookupId(uid) + if err == nil { + return u, nil + } + + stdErr := err + log.Debugf("os/user.LookupId(%q) failed, trying getent: %v", uid, err) + + u, _, getentErr := passwdLookup(uid) + if getentErr != nil { + log.Debugf("getent fallback for uid %s also failed: %v", uid, getentErr) + return nil, stdErr + } + return u, nil +} + +// CurrentUser returns the user this process runs as, falling back to getent +// if os/user fails. +func CurrentUser() (*user.User, error) { + u, err := user.Current() + if err == nil { + return u, nil + } + + stdErr := err + uid := strconv.Itoa(os.Getuid()) + log.Debugf("os/user.Current() failed, trying getent with UID %s: %v", uid, err) + + u, _, getentErr := passwdLookup(uid) + if getentErr != nil { + return nil, stdErr + } + return u, nil +} + +// LookupGroupID looks up a group by GID, falling back to getent if os/user +// fails. +func LookupGroupID(gid string) (*user.Group, error) { + g, err := user.LookupGroupId(gid) + if err == nil { + return g, nil + } + + stdErr := err + log.Debugf("os/user.LookupGroupId(%q) failed, trying getent: %v", gid, err) + + g, _, getentErr := groupLookup(gid) + if getentErr != nil { + log.Debugf("getent fallback for gid %s also failed: %v", gid, getentErr) + return nil, stdErr + } + return g, nil +} + +// GroupIDs returns the IDs of the groups the user is a member of. +// NOTE: unlike the lookups above, which try the standard library first, this +// intentionally tries `id -G` first because without cgo, user.GroupIds only +// reads /etc/group and silently returns incomplete results for NSS users +// (no error, just missing groups). The id command goes through NSS and +// returns the full set. +func GroupIDs(u *user.User) ([]string, error) { + ids, err := idGroups(u.Username) + if err == nil { + return ids, nil + } + + log.Debugf("id -G %q failed, falling back to user.GroupIds(): %v", u.Username, err) + + ids, stdErr := u.GroupIds() + if stdErr != nil { + return nil, stdErr + } + return ids, nil +} diff --git a/client/internal/getent/unix.go b/client/internal/getent/unix.go new file mode 100644 index 000000000..7d29810f5 --- /dev/null +++ b/client/internal/getent/unix.go @@ -0,0 +1,224 @@ +//go:build !windows + +package getent + +import ( + "bufio" + "context" + "fmt" + "os" + "os/exec" + "os/user" + "runtime" + "strings" + "time" + + log "github.com/sirupsen/logrus" +) + +const commandTimeout = 5 * time.Second + +// groupFile lists which accounts are in which group, for hosts where the +// getent command is not available (macOS ships without it). +const groupFile = "/etc/group" + +// UserShell returns the login shell getent reports for the user with this UID. +// It reaches shells that /etc/passwd does not list, because getent resolves +// through the host's NSS stack. +func UserShell(uid string) (string, error) { + _, shell, err := passwdLookup(uid) + if err != nil { + return "", err + } + return shell, nil +} + +// GroupMembers returns the names of the group's members: from getent, which +// resolves through NSS, or from /etc/group where getent is not available. A +// group neither source describes is an error; an empty member list is not, +// since accounts with the group as their primary one are not listed in it. +func GroupMembers(name string) ([]string, error) { + _, members, err := groupLookup(name) + if err == nil { + return members, nil + } + log.Debugf("getent cannot list group %q, reading %s: %v", name, groupFile, err) + return groupMembersFromFile(groupFile, name) +} + +// passwdLookup executes `getent passwd `, where query is a username or +// UID, and returns the user and login shell. +func passwdLookup(query string) (*user.User, string, error) { + out, err := run("passwd", query) + if err != nil { + return nil, "", err + } + return parsePasswd(string(out)) +} + +// groupLookup executes `getent group `, where query is a group name or +// GID, and returns the group and its member names. +func groupLookup(query string) (*user.Group, []string, error) { + out, err := run("group", query) + if err != nil { + return nil, nil, err + } + return parseGroup(string(out)) +} + +// run executes `getent ` with a timeout. +func run(database, key string) ([]byte, error) { + if !validateInput(key) { + return nil, fmt.Errorf("invalid getent input: %q", key) + } + + ctx, cancel := context.WithTimeout(context.Background(), commandTimeout) + defer cancel() + + out, err := exec.CommandContext(ctx, "getent", database, key).Output() + if err != nil { + return nil, fmt.Errorf("getent %s %s: %w", database, key, err) + } + return out, nil +} + +// parsePasswd parses getent passwd output: "name:x:uid:gid:gecos:home:shell" +func parsePasswd(output string) (*user.User, string, error) { + fields := strings.SplitN(strings.TrimSpace(output), ":", 8) + if len(fields) < 6 { + return nil, "", fmt.Errorf("unexpected getent output (need 6+ fields): %q", output) + } + + if fields[0] == "" || fields[2] == "" || fields[3] == "" { + return nil, "", fmt.Errorf("missing required fields in getent output: %q", output) + } + + var shell string + if len(fields) >= 7 { + shell = fields[6] + } + + return &user.User{ + Username: fields[0], + Uid: fields[2], + Gid: fields[3], + Name: fields[4], + HomeDir: fields[5], + }, shell, nil +} + +// parseGroup parses getent group output: "name:x:gid:member,member" +func parseGroup(output string) (*user.Group, []string, error) { + fields := strings.SplitN(strings.TrimSpace(output), ":", 4) + if len(fields) < 3 { + return nil, nil, fmt.Errorf("unexpected getent output (need 3+ fields): %q", output) + } + + if fields[0] == "" || fields[2] == "" { + return nil, nil, fmt.Errorf("missing required fields in getent output: %q", output) + } + + var members []string + if len(fields) >= 4 { + members = splitMembers(fields[3]) + } + return &user.Group{Name: fields[0], Gid: fields[2]}, members, nil +} + +func splitMembers(list string) []string { + var members []string + for member := range strings.SplitSeq(list, ",") { + if member != "" { + members = append(members, member) + } + } + return members +} + +// groupMembersFromFile finds the group's member list in a file of /etc/group's +// format. A group the file does not describe, because it comes from LDAP or +// another NSS source, is an error rather than an empty list. +func groupMembersFromFile(path, name string) ([]string, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open %s: %w", path, err) + } + defer func() { + if err := file.Close(); err != nil { + log.Debugf("close %s: %v", path, err) + } + }() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + // name:password:gid:member,member + fields := strings.Split(scanner.Text(), ":") + if len(fields) < 4 || fields[0] != name { + continue + } + return splitMembers(fields[3]), nil + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + return nil, fmt.Errorf("%s does not describe group %q", path, name) +} + +// validateInput checks that the input is safe to pass to getent or id. +// Allows POSIX usernames, numeric IDs, and common NSS extensions +// (@ for Kerberos, $ for Samba, + for NIS compat). A leading hyphen is +// rejected so the input can never be parsed as a command-line flag. +func validateInput(input string) bool { + maxLen := 32 + if runtime.GOOS == "linux" { + maxLen = 256 + } + + if len(input) == 0 || len(input) > maxLen { + return false + } + + if input[0] == '-' { + return false + } + + for _, r := range input { + if isAllowedChar(r) { + continue + } + return false + } + return true +} + +func isAllowedChar(r rune) bool { + if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' { + return true + } + switch r { + case '.', '_', '-', '@', '+', '$': + return true + } + return false +} + +// idGroups runs `id -G ` and returns the space-separated group IDs. +func idGroups(username string) ([]string, error) { + if !validateInput(username) { + return nil, fmt.Errorf("invalid username for id command: %q", username) + } + + ctx, cancel := context.WithTimeout(context.Background(), commandTimeout) + defer cancel() + + out, err := exec.CommandContext(ctx, "id", "-G", username).Output() + if err != nil { + return nil, fmt.Errorf("id -G %s: %w", username, err) + } + + trimmed := strings.TrimSpace(string(out)) + if trimmed == "" { + return nil, fmt.Errorf("id -G %s: empty output", username) + } + return strings.Fields(trimmed), nil +} diff --git a/client/ssh/server/getent_unix_test.go b/client/internal/getent/unix_test.go similarity index 62% rename from client/ssh/server/getent_unix_test.go rename to client/internal/getent/unix_test.go index e44563b79..5ab100ce5 100644 --- a/client/ssh/server/getent_unix_test.go +++ b/client/internal/getent/unix_test.go @@ -1,10 +1,12 @@ //go:build !windows -package server +package getent import ( + "os" "os/exec" "os/user" + "path/filepath" "runtime" "strconv" "testing" @@ -13,7 +15,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestParseGetentPasswd(t *testing.T) { +func TestParsePasswd(t *testing.T) { tests := []struct { name string input string @@ -128,7 +130,7 @@ func TestParseGetentPasswd(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - u, shell, err := parseGetentPasswd(tt.input) + u, shell, err := parsePasswd(tt.input) if tt.wantErr { require.Error(t, err) if tt.errContains != "" { @@ -147,7 +149,120 @@ func TestParseGetentPasswd(t *testing.T) { } } -func TestValidateGetentInput(t *testing.T) { +func TestParseGroup(t *testing.T) { + tests := []struct { + name string + input string + wantGroup *user.Group + wantMembers []string + wantErr bool + }{ + { + name: "no members", + input: "vma:x:1000:\n", + wantGroup: &user.Group{Name: "vma", Gid: "1000"}, + }, + { + name: "one member", + input: "sudo:x:27:alice", + wantGroup: &user.Group{Name: "sudo", Gid: "27"}, + wantMembers: []string{"alice"}, + }, + { + name: "several members", + input: "docker:x:998:alice,bob\n", + wantGroup: &user.Group{Name: "docker", Gid: "998"}, + wantMembers: []string{"alice", "bob"}, + }, + { + name: "too few fields", + input: "bad:x", + wantErr: true, + }, + { + name: "empty group name", + input: ":x:1000:alice", + wantErr: true, + }, + { + name: "empty GID", + input: "vma:x::alice", + wantErr: true, + }, + { + name: "empty input", + input: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g, members, err := parseGroup(tt.input) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantGroup.Name, g.Name, "group name") + assert.Equal(t, tt.wantGroup.Gid, g.Gid, "GID") + assert.Equal(t, tt.wantMembers, members, "members") + }) + } +} + +func TestGroupMembersFromFile(t *testing.T) { + tests := []struct { + name string + entry string + want []string + }{ + {name: "no members", entry: "vma:x:1000:"}, + {name: "only the owner", entry: "vma:x:1000:vma", want: []string{"vma"}}, + {name: "two members", entry: "vma:x:1000:vma,bob", want: []string{"vma", "bob"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "group") + body := "root:x:0:\n" + tt.entry + "\nsudo:x:27:vma\n" + require.NoError(t, os.WriteFile(path, []byte(body), 0o644), "write the group file") + + members, err := groupMembersFromFile(path, "vma") + require.NoError(t, err, "entry %q", tt.entry) + assert.Equal(t, tt.want, members, "entry %q", tt.entry) + }) + } +} + +// A group the file does not describe, because it comes from LDAP or another +// NSS source, is an error rather than an empty member list: the caller must +// be able to tell "no members" from "no answer". +func TestGroupMembersFromFileUnknownGroup(t *testing.T) { + path := filepath.Join(t.TempDir(), "group") + require.NoError(t, os.WriteFile(path, []byte("root:x:0:\n"), 0o644), "write the group file") + + _, err := groupMembersFromFile(path, "vma") + assert.Error(t, err, "a group the file does not describe") + + _, err = groupMembersFromFile(filepath.Join(t.TempDir(), "absent"), "vma") + assert.Error(t, err, "no group file at all") +} + +// GroupMembers on the root group, which every Unix has, whichever source +// answers for it. +func TestGroupMembers_RootGroup(t *testing.T) { + rootGroup := "root" + switch runtime.GOOS { + case "darwin", "dragonfly", "freebsd", "netbsd", "openbsd": + rootGroup = "wheel" + } + + _, err := GroupMembers(rootGroup) + assert.NoError(t, err, "the %s group must be describable", rootGroup) +} + +func TestValidateInput(t *testing.T) { tests := []struct { name string input string @@ -157,6 +272,9 @@ func TestValidateGetentInput(t *testing.T) { {"numeric UID", "1001", true}, {"dots and underscores", "alice.bob_test", true}, {"hyphen", "alice-bob", true}, + {"leading hyphen rejected", "-i", false}, + {"leading double hyphen rejected", "--no-idn", false}, + {"lone hyphen rejected", "-", false}, {"kerberos principal", "user@REALM", true}, {"samba machine account", "MACHINE$", true}, {"NIS compat", "+user", true}, @@ -177,7 +295,7 @@ func TestValidateGetentInput(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, validateGetentInput(tt.input)) + assert.Equal(t, tt.want, validateInput(tt.input)) }) } } @@ -190,12 +308,12 @@ func makeLongString(n int) string { return string(b) } -func TestRunGetent_RootUser(t *testing.T) { +func TestPasswdLookup_RootUser(t *testing.T) { if _, err := exec.LookPath("getent"); err != nil { t.Skip("getent not available on this system") } - u, shell, err := runGetent("root") + u, shell, err := passwdLookup("root") require.NoError(t, err) assert.Equal(t, "root", u.Username) assert.Equal(t, "0", u.Uid) @@ -203,44 +321,55 @@ func TestRunGetent_RootUser(t *testing.T) { assert.NotEmpty(t, shell, "root should have a shell") } -func TestRunGetent_ByUID(t *testing.T) { +func TestPasswdLookup_ByUID(t *testing.T) { if _, err := exec.LookPath("getent"); err != nil { t.Skip("getent not available on this system") } - u, _, err := runGetent("0") + u, _, err := passwdLookup("0") require.NoError(t, err) assert.Equal(t, "root", u.Username) assert.Equal(t, "0", u.Uid) } -func TestRunGetent_NonexistentUser(t *testing.T) { +func TestPasswdLookup_NonexistentUser(t *testing.T) { if _, err := exec.LookPath("getent"); err != nil { t.Skip("getent not available on this system") } - _, _, err := runGetent("nonexistent_user_xyzzy_12345") + _, _, err := passwdLookup("nonexistent_user_xyzzy_12345") assert.Error(t, err) } -func TestRunGetent_InvalidInput(t *testing.T) { - _, _, err := runGetent("") +func TestPasswdLookup_InvalidInput(t *testing.T) { + _, _, err := passwdLookup("") assert.Error(t, err) - _, _, err = runGetent("user\x00name") + _, _, err = passwdLookup("user\x00name") assert.Error(t, err) } -func TestRunGetent_NotAvailable(t *testing.T) { +func TestPasswdLookup_NotAvailable(t *testing.T) { if _, err := exec.LookPath("getent"); err == nil { t.Skip("getent is available, can't test missing case") } - _, _, err := runGetent("root") + _, _, err := passwdLookup("root") assert.Error(t, err, "should fail when getent is not installed") } -func TestRunIdGroups_CurrentUser(t *testing.T) { +func TestGroupLookup_RootGroup(t *testing.T) { + if _, err := exec.LookPath("getent"); err != nil { + t.Skip("getent not available on this system") + } + + g, _, err := groupLookup("0") + require.NoError(t, err) + assert.Equal(t, "0", g.Gid, "GID 0 resolves to the root group") + assert.NotEmpty(t, g.Name, "the root group has a name") +} + +func TestIdGroups_CurrentUser(t *testing.T) { if _, err := exec.LookPath("id"); err != nil { t.Skip("id not available on this system") } @@ -248,7 +377,7 @@ func TestRunIdGroups_CurrentUser(t *testing.T) { current, err := user.Current() require.NoError(t, err) - groups, err := runIdGroups(current.Username) + groups, err := idGroups(current.Username) require.NoError(t, err) require.NotEmpty(t, groups, "current user should have at least one group") @@ -258,20 +387,20 @@ func TestRunIdGroups_CurrentUser(t *testing.T) { } } -func TestRunIdGroups_NonexistentUser(t *testing.T) { +func TestIdGroups_NonexistentUser(t *testing.T) { if _, err := exec.LookPath("id"); err != nil { t.Skip("id not available on this system") } - _, err := runIdGroups("nonexistent_user_xyzzy_12345") + _, err := idGroups("nonexistent_user_xyzzy_12345") assert.Error(t, err) } -func TestRunIdGroups_InvalidInput(t *testing.T) { - _, err := runIdGroups("") +func TestIdGroups_InvalidInput(t *testing.T) { + _, err := idGroups("") assert.Error(t, err) - _, err = runIdGroups("user\x00name") + _, err = idGroups("user\x00name") assert.Error(t, err) } @@ -283,7 +412,7 @@ func TestGetentResultsMatchStdlib(t *testing.T) { current, err := user.Current() require.NoError(t, err) - getentUser, _, err := runGetent(current.Username) + getentUser, _, err := passwdLookup(current.Username) require.NoError(t, err) assert.Equal(t, current.Username, getentUser.Username, "username should match") @@ -300,7 +429,7 @@ func TestGetentResultsMatchStdlib_ByUID(t *testing.T) { current, err := user.Current() require.NoError(t, err) - getentUser, _, err := runGetent(current.Uid) + getentUser, _, err := passwdLookup(current.Uid) require.NoError(t, err) assert.Equal(t, current.Username, getentUser.Username, "username should match when looked up by UID") @@ -320,12 +449,12 @@ func TestIdGroupsMatchStdlib(t *testing.T) { t.Skip("os/user.GroupIds() not working, likely CGO_ENABLED=0") } - idGroups, err := runIdGroups(current.Username) + idGroupIDs, err := idGroups(current.Username) require.NoError(t, err) // Deduplicate both lists: id -G can return duplicates (e.g., root in Docker) // and ElementsMatch treats duplicates as distinct. - assert.ElementsMatch(t, uniqueStrings(stdGroups), uniqueStrings(idGroups), "id -G should return same groups as os/user") + assert.ElementsMatch(t, uniqueStrings(stdGroups), uniqueStrings(idGroupIDs), "id -G should return same groups as os/user") } func uniqueStrings(ss []string) []string { @@ -340,71 +469,3 @@ func uniqueStrings(ss []string) []string { } return out } - -// TestGetShellFromPasswd_CurrentUser verifies that getShellFromPasswd correctly -// reads the current user's shell from /etc/passwd by comparing it against what -// getent reports (which goes through NSS). -func TestGetShellFromPasswd_CurrentUser(t *testing.T) { - current, err := user.Current() - require.NoError(t, err) - - shell := getShellFromPasswd(current.Uid) - if shell == "" { - t.Skip("current user not found in /etc/passwd (may be an NSS-only user)") - } - - assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell) - - if _, err := exec.LookPath("getent"); err == nil { - _, getentShell, getentErr := runGetent(current.Uid) - if getentErr == nil && getentShell != "" { - assert.Equal(t, getentShell, shell, "shell from /etc/passwd should match getent") - } - } -} - -// TestGetShellFromPasswd_RootUser verifies that getShellFromPasswd can read -// root's shell from /etc/passwd. Root is guaranteed to be in /etc/passwd on -// any standard Unix system. -func TestGetShellFromPasswd_RootUser(t *testing.T) { - shell := getShellFromPasswd("0") - require.NotEmpty(t, shell, "root (UID 0) must be in /etc/passwd") - assert.True(t, shell[0] == '/', "root shell should be an absolute path, got %q", shell) -} - -// TestGetShellFromPasswd_NonexistentUID verifies that getShellFromPasswd -// returns empty for a UID that doesn't exist in /etc/passwd. -func TestGetShellFromPasswd_NonexistentUID(t *testing.T) { - shell := getShellFromPasswd("4294967294") - assert.Empty(t, shell, "nonexistent UID should return empty shell") -} - -// TestGetShellFromPasswd_MatchesGetentForKnownUsers reads /etc/passwd directly -// and cross-validates every entry against getent to ensure parseGetentPasswd -// and getShellFromPasswd agree on shell values. -func TestGetShellFromPasswd_MatchesGetentForKnownUsers(t *testing.T) { - if _, err := exec.LookPath("getent"); err != nil { - t.Skip("getent not available") - } - - // Pick a few well-known system UIDs that are virtually always in /etc/passwd. - uids := []string{"0"} // root - - current, err := user.Current() - require.NoError(t, err) - uids = append(uids, current.Uid) - - for _, uid := range uids { - passwdShell := getShellFromPasswd(uid) - if passwdShell == "" { - continue - } - - _, getentShell, err := runGetent(uid) - if err != nil { - continue - } - - assert.Equal(t, getentShell, passwdShell, "shell mismatch for UID %s", uid) - } -} diff --git a/client/internal/getent/windows.go b/client/internal/getent/windows.go new file mode 100644 index 000000000..61881d162 --- /dev/null +++ b/client/internal/getent/windows.go @@ -0,0 +1,36 @@ +//go:build windows + +package getent + +import ( + "errors" + "os/user" +) + +// Windows does not use NSS or getent; os/user resolves accounts there +// without cgo, so everything delegates to it. + +// LookupUser looks up a user by name. +func LookupUser(username string) (*user.User, error) { + return user.Lookup(username) +} + +// LookupUserID looks up a user by UID. +func LookupUserID(uid string) (*user.User, error) { + return user.LookupId(uid) +} + +// CurrentUser returns the user this process runs as. +func CurrentUser() (*user.User, error) { + return user.Current() +} + +// GroupIDs returns the IDs of the groups the user is a member of. +func GroupIDs(u *user.User) ([]string, error) { + return u.GroupIds() +} + +// UserShell is unanswerable on Windows, which has no login-shell database. +func UserShell(string) (string, error) { + return "", errors.ErrUnsupported +} diff --git a/client/internal/ingressgw/manager.go b/client/internal/ingressgw/manager.go index b8952e5c0..605543d1c 100644 --- a/client/internal/ingressgw/manager.go +++ b/client/internal/ingressgw/manager.go @@ -24,14 +24,14 @@ type RulePair struct { type Manager struct { dnatFirewall DNATFirewall - rules map[string]RulePair // keys is the ID of the ForwardRule + rules map[firewall.RuleID]RulePair rulesMu sync.Mutex } func NewManager(dnatFirewall DNATFirewall) *Manager { return &Manager{ dnatFirewall: dnatFirewall, - rules: make(map[string]RulePair), + rules: make(map[firewall.RuleID]RulePair), } } @@ -41,7 +41,7 @@ func (h *Manager) Update(forwardRules []firewall.ForwardRule) error { var mErr *multierror.Error - toDelete := make(map[string]RulePair, len(h.rules)) + toDelete := make(map[firewall.RuleID]RulePair, len(h.rules)) for id, r := range h.rules { toDelete[id] = r } @@ -59,6 +59,10 @@ func (h *Manager) Update(forwardRules []firewall.ForwardRule) error { mErr = multierror.Append(mErr, fmt.Errorf("add forward rule '%s': %v", fwdRule.String(), err)) continue } + if rule == nil { + mErr = multierror.Append(mErr, fmt.Errorf("add forward rule '%s': backend returned no rule", fwdRule.String())) + continue + } log.Infof("forward rule has been added '%s'", fwdRule) h.rules[id] = RulePair{ ForwardRule: fwdRule, @@ -90,7 +94,7 @@ func (h *Manager) Close() error { } } - h.rules = make(map[string]RulePair) + h.rules = make(map[firewall.RuleID]RulePair) return nberrors.FormatErrorOrNil(mErr) } diff --git a/client/internal/ingressgw/manager_test.go b/client/internal/ingressgw/manager_test.go index 591ea0dd8..0cd40fcc4 100644 --- a/client/internal/ingressgw/manager_test.go +++ b/client/internal/ingressgw/manager_test.go @@ -14,11 +14,11 @@ var ( ) type MocFwRule struct { - id string + id firewall.RuleID } -func (m *MocFwRule) ID() string { - return string(m.id) +func (m *MocFwRule) ID() firewall.RuleID { + return m.id } type MockDNATFirewall struct { diff --git a/client/internal/ipcauth/creds_stub.go b/client/internal/ipcauth/creds_stub.go new file mode 100644 index 000000000..154948716 --- /dev/null +++ b/client/internal/ipcauth/creds_stub.go @@ -0,0 +1,31 @@ +//go:build !linux && !darwin && !freebsd && !windows + +package ipcauth + +import ( + "errors" + "net" + + "google.golang.org/grpc/credentials" +) + +// errUnsupported is returned on platforms with no local peer-identity +// primitive, so consumers fail closed instead of guessing an identity. +var errUnsupported = errors.New("peer identity is not available on this platform") + +// NewTransportCredentials returns nil: without a peer-identity primitive the +// daemon cannot authenticate local callers, and the caller must treat that as +// "authorization cannot be enforced". +func NewTransportCredentials() credentials.TransportCredentials { + return nil +} + +// PeerIdentity always fails on this platform. +func PeerIdentity(net.Conn) (Identity, error) { + return Identity{}, errUnsupported +} + +// ConnIdentity always fails on this platform. +func ConnIdentity(net.Conn) (Identity, error) { + return Identity{}, errUnsupported +} diff --git a/client/internal/ipcauth/creds_unix.go b/client/internal/ipcauth/creds_unix.go new file mode 100644 index 000000000..688fe4623 --- /dev/null +++ b/client/internal/ipcauth/creds_unix.go @@ -0,0 +1,56 @@ +//go:build linux || darwin || freebsd + +package ipcauth + +import ( + "context" + "net" + + "google.golang.org/grpc/credentials" +) + +// NewTransportCredentials returns gRPC transport credentials that expose the +// caller's kernel-authenticated identity via IdentityFromContext. It returns +// nil on platforms that have no peer-identity primitive, which the caller must +// treat as "authorization cannot be enforced". +// +// The handshake exchanges no bytes on the wire, so a client dialing with +// insecure credentials interoperates with a server using these. That keeps +// older CLI and UI binaries working against an upgraded daemon. +func NewTransportCredentials() credentials.TransportCredentials { + return unixCreds{} +} + +// ConnIdentity extracts the caller's identity from an accepted local IPC +// connection. It is shared by the gRPC transport credentials and by the JSON +// gateway, which reads the identity of its own HTTP clients. +func ConnIdentity(conn net.Conn) (Identity, error) { + return PeerIdentity(conn) +} + +type unixCreds struct{} + +func (unixCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) { + return conn, AuthInfo{}, nil +} + +// ServerHandshake extracts the peer identity and fails closed when it cannot +// be read, so a connection whose caller is unknown never reaches a handler. +func (unixCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) { + id, err := ConnIdentity(conn) + if err != nil { + return nil, nil, err + } + return conn, AuthInfo{ + CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity}, + Identity: id, + }, nil +} + +func (unixCreds) Info() credentials.ProtocolInfo { + return credentials.ProtocolInfo{SecurityProtocol: AuthInfo{}.AuthType()} +} + +func (unixCreds) Clone() credentials.TransportCredentials { return unixCreds{} } + +func (unixCreds) OverrideServerName(string) error { return nil } diff --git a/client/internal/ipcauth/creds_windows.go b/client/internal/ipcauth/creds_windows.go new file mode 100644 index 000000000..37f902c52 --- /dev/null +++ b/client/internal/ipcauth/creds_windows.go @@ -0,0 +1,194 @@ +//go:build windows + +package ipcauth + +import ( + "context" + "fmt" + "net" + "runtime" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" + "google.golang.org/grpc/credentials" +) + +var ( + modadvapi32 = windows.NewLazySystemDLL("advapi32.dll") + procImpersonateNamedPipeClient = modadvapi32.NewProc("ImpersonateNamedPipeClient") +) + +// DefaultPipeSDDL is the security descriptor for the daemon control pipe. +// +// D:P protected DACL, no inheritance +// (A;;GA;;;SY) allow GENERIC_ALL to LocalSystem (the daemon's service account) +// (A;;GA;;;WD) allow GENERIC_ALL to Everyone +// +// Any local caller may connect, as with a Unix socket at 0666; what a caller may +// actually do is decided from its token, not from the DACL. Remote callers are not +// a concern here: winio.ListenPipe creates the pipe with +// FILE_PIPE_REJECT_REMOTE_CLIENTS, so NPFS rejects connections from other machines +// before the descriptor is consulted. +// +// A deny ACE on the NETWORK SID would not add anything and would break callers: +// that SID is present in any network-logon token, which includes OpenSSH and WinRM +// sessions, so it denies administrators driving the CLI over SSH and denies the +// daemon itself when started from such a session. +func DefaultPipeSDDL() string { + return "D:P(A;;GA;;;SY)(A;;GA;;;WD)" +} + +// NewTransportCredentials returns gRPC transport credentials that derive the +// caller's identity from the named-pipe client token. +// +// The client must connect at SECURITY_IDENTIFICATION for the daemon to be able +// to read its token, which is what DialNamedPipe does. +func NewTransportCredentials() credentials.TransportCredentials { + return winpipeCreds{} +} + +// ConnIdentity extracts the caller's identity from an accepted named-pipe +// connection by impersonating the pipe client and reading its token. It is +// shared by the gRPC transport credentials and by the JSON gateway, which +// reads the identity of its own HTTP clients. +func ConnIdentity(conn net.Conn) (Identity, error) { + // go-winio's pipe connection embeds *win32File, which exposes Fd(). + fdConn, ok := conn.(interface{ Fd() uintptr }) + if !ok { + return Identity{}, fmt.Errorf("connection %T does not expose a pipe handle", conn) + } + return pipeClientIdentity(windows.Handle(fdConn.Fd())) +} + +type winpipeCreds struct{} + +func (winpipeCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) { + return conn, AuthInfo{}, nil +} + +// ServerHandshake extracts the connecting client's identity and fails closed +// when the handle or token cannot be read, so a connection whose caller is +// unknown never reaches a handler. +func (winpipeCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) { + id, err := ConnIdentity(conn) + if err != nil { + return nil, nil, err + } + return conn, AuthInfo{ + CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity}, + Identity: id, + }, nil +} + +func (winpipeCreds) Info() credentials.ProtocolInfo { + return credentials.ProtocolInfo{SecurityProtocol: AuthInfo{}.AuthType()} +} + +func (winpipeCreds) Clone() credentials.TransportCredentials { return winpipeCreds{} } + +func (winpipeCreds) OverrideServerName(string) error { return nil } + +// pipeClientIdentity reads the connecting client's user SID, usable group +// SIDs, and elevation state by impersonating the pipe client on this thread +// and reading the resulting impersonation token. +func pipeClientIdentity(handle windows.Handle) (id Identity, err error) { + // Impersonation is per-thread, so the goroutine must stay on this thread + // until RevertToSelf, otherwise an unrelated goroutine could inherit the + // impersonated context. + runtime.LockOSThread() + + // The thread only goes back to the runtime's pool once it is provably no + // longer impersonating the client. If the revert fails, leaving it locked + // makes Go terminate it when this goroutine exits, which costs one thread + // and keeps a thread running as the client from ever being reused. + clean := false + defer func() { + if clean { + runtime.UnlockOSThread() + } + }() + + if err = impersonateNamedPipeClient(handle); err != nil { + clean = true + return Identity{}, fmt.Errorf("impersonate named pipe client: %w", err) + } + defer func() { + // Surface the revert failure only when nothing else failed: leaving + // the thread impersonated is worse than the original error. + revErr := windows.RevertToSelf() + if revErr != nil { + if err == nil { + err = fmt.Errorf("revert impersonation: %w", revErr) + } + return + } + clean = true + }() + + // openAsSelf=true opens the token with the daemon's own process context + // rather than the impersonated client's, so the open cannot fail because + // the client lacks access to its own token. + var token windows.Token + if err = windows.OpenThreadToken(windows.CurrentThread(), windows.TOKEN_QUERY, true, &token); err != nil { + return Identity{}, fmt.Errorf("open thread token: %w", err) + } + defer func() { + if cerr := token.Close(); cerr != nil { + log.Debugf("close client token: %v", cerr) + } + }() + + return identityFromToken(token) +} + +// identityFromToken reads the user SID, usable group SIDs and elevation state +// out of a Windows token. +func identityFromToken(token windows.Token) (Identity, error) { + user, err := token.GetTokenUser() + if err != nil { + return Identity{}, fmt.Errorf("read token user: %w", err) + } + + groups, err := tokenGroupSIDs(token) + if err != nil { + return Identity{}, err + } + + return Identity{ + SID: user.User.Sid.String(), + Groups: groups, + Elevated: token.IsElevated(), + }, nil +} + +// tokenGroupSIDs returns the SIDs of the groups the token can actually +// exercise. Groups that are disabled or marked deny-only are skipped: a +// UAC-filtered administrator carries BUILTIN\Administrators as deny-only, and +// treating that as membership would hand every admin account privilege it +// cannot currently use. +func tokenGroupSIDs(token windows.Token) ([]string, error) { + tg, err := token.GetTokenGroups() + if err != nil { + return nil, fmt.Errorf("read token groups: %w", err) + } + + var sids []string + for _, g := range tg.AllGroups() { + if g.Attributes&windows.SE_GROUP_ENABLED == 0 { + continue + } + if g.Attributes&windows.SE_GROUP_USE_FOR_DENY_ONLY != 0 { + continue + } + sids = append(sids, g.Sid.String()) + } + return sids, nil +} + +func impersonateNamedPipeClient(h windows.Handle) error { + r, _, e := procImpersonateNamedPipeClient.Call(uintptr(h)) + if r == 0 { + return e + } + return nil +} diff --git a/client/internal/ipcauth/forward.go b/client/internal/ipcauth/forward.go new file mode 100644 index 000000000..57749528c --- /dev/null +++ b/client/internal/ipcauth/forward.go @@ -0,0 +1,272 @@ +package ipcauth + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "fmt" + "slices" + "strconv" + "strings" + + "google.golang.org/grpc/metadata" +) + +// Metadata keys the local JSON gateway uses to forward the identity of its own +// HTTP client to the daemon. The gateway runs inside the daemon process and +// re-dials the daemon over the control socket, so without forwarding every +// JSON request would appear to come from the daemon itself. +const ( + // mdFwd marks a request as forwarded by the JSON gateway. It is always + // set, even when the gateway could not read its client's identity, so the + // daemon can tell "no identity available" apart from "not forwarded". + mdFwd = "x-netbird-fwd" + mdFwdUID = "x-netbird-fwd-uid" // Unix user ID + mdFwdGID = "x-netbird-fwd-gid" // Unix primary group ID + mdFwdSID = "x-netbird-fwd-sid" // Windows user SID + mdFwdGroup = "x-netbird-fwd-group" // Windows group SID, repeated + mdFwdElevated = "x-netbird-fwd-elevated" // Windows, "1" when elevated + + // mdFwdProof proves the forwarded identity was stamped by this process. The + // gateway runs inside the daemon, so a secret held in memory is available to + // the only legitimate producer and to nothing else. + mdFwdProof = "x-netbird-fwd-proof" +) + +// forwardKeys is every metadata key the gateway sets. An HTTP client must never +// be able to supply one itself: see IsReservedForwardKey. +var forwardKeys = []string{mdFwd, mdFwdUID, mdFwdGID, mdFwdSID, mdFwdGroup, mdFwdElevated, mdFwdProof} + +// forwardProof authenticates the gateway's forwarding metadata. It is generated +// once per daemon process and never leaves it: it is not written to disk, not +// logged, and not sent anywhere except over the daemon's own control socket to +// itself. +// +// Without it, trusting a forwarded identity rests on every layer in front of it +// stripping incoming forwarding keys, and on each key's value shape being +// distinguishable from an injected one. A single injected group SID or an +// injected "elevated" flag has the same shape as a legitimate one, so no +// cardinality rule can catch it. Requiring the proof means metadata that did not +// come from this process is refused whatever it contains. +var forwardProof = mustForwardProof() + +func mustForwardProof() string { + var buf [32]byte + if _, err := rand.Read(buf[:]); err != nil { + // Continuing would leave the forwarded path authenticated by a + // predictable value, which is worse than not starting. + panic(fmt.Sprintf("generate identity forwarding proof: %v", err)) + } + return hex.EncodeToString(buf[:]) +} + +// IsReservedForwardKey reports whether a gRPC metadata key belongs to the +// gateway's identity forwarding, and therefore must be dropped when it arrives +// from outside. +// +// grpc-gateway maps "Grpc-Metadata-" request headers into gRPC metadata and +// joins them ahead of the values its own annotators add. Without dropping these, +// an HTTP client could hand the daemon "x-netbird-fwd-uid: 0" and be believed, +// because the daemon trusts forwarded metadata when the transport peer is the +// (privileged) gateway. +func IsReservedForwardKey(key string) bool { + key = strings.ToLower(key) + return slices.Contains(forwardKeys, key) +} + +// ForwardIdentityMetadata encodes an HTTP client's identity for the JSON +// gateway to forward to the daemon. When known is false only the marker is +// set, which makes the daemon treat the caller as unidentified rather than as +// the daemon itself. +func ForwardIdentityMetadata(id Identity, known bool) metadata.MD { + md := metadata.MD{} + md.Set(mdFwd, "1") + md.Set(mdFwdProof, forwardProof) + if !known { + return md + } + + if id.IsWindows() { + md.Set(mdFwdSID, id.SID) + if len(id.Groups) > 0 { + md.Set(mdFwdGroup, id.Groups...) + } + if id.Elevated { + md.Set(mdFwdElevated, "1") + } + return md + } + + md.Set(mdFwdUID, strconv.FormatUint(uint64(id.UID), 10)) + md.Set(mdFwdGID, strconv.FormatUint(uint64(id.GID), 10)) + return md +} + +// CallerIdentity returns the identity to authorize a request against. For a +// direct connection that is the transport peer's kernel identity. For a +// request relayed by the local JSON gateway it is the identity the gateway +// forwarded, since the transport peer is then the daemon itself. +// +// A forwarded identity is only honoured when the transport peer is the daemon's +// own identity and the metadata carries this process's forwarding proof, so +// forged forwarding metadata gains a caller nothing. A forwarded request that +// carries no identity is reported as unidentified, never as the daemon. +// +// The second return value is false when no identity could be established, and +// callers MUST fail closed in that case. +func CallerIdentity(ctx context.Context) (Identity, bool) { + id, ok := IdentityFromContext(ctx) + if !ok { + return Identity{}, false + } + + // A forwarding key that arrives more than once did not come from the gateway + // alone, so nothing about the request can be trusted to describe its caller. + // Refusing outright matters because the alternative reading, "not forwarded", + // would authorize the request as the transport peer, which on the gateway's + // connection is the daemon itself. + if duplicatedForwardKey(ctx) { + return Identity{}, false + } + + forwarded := isForwarded(ctx) + + // Our own process on the other end of the socket is the JSON gateway, the only + // thing that dials the daemon from inside it. Such a call must carry a + // forwarded identity; without one there is no caller to authorize, and + // treating it as the daemon would authorize whatever reached the JSON socket. + // Only Linux reports the peer PID, so this is a belt on top of the gateway's + // interceptor rather than the sole guarantee. + if id.PID != 0 && int(id.PID) == selfPID && !forwarded { + return Identity{}, false + } + + // Only the gateway's own connection may speak for someone else. Being + // privileged is not enough and not the point: the gateway runs inside the + // daemon, so it dials as the daemon's identity whatever user that is, which + // also covers a rootless container. + if !forwarded || !IsDaemonSelf(id) { + return id, true + } + + // Speaking for someone else additionally requires the proof only this process + // holds. Refusing is the only safe reading: the transport peer here is the + // daemon itself, so falling back to it would authorize the request as the + // daemon. This is also what makes the forwarded values trustworthy once + // accepted, so they need no shape checks of their own. + if !authenticForward(ctx) { + return Identity{}, false + } + + return forwardedIdentity(ctx) +} + +// duplicatedForwardKey reports whether any forwarding key carries more than one +// value. The gateway's interceptor sets each key exactly once and replaces what +// was already there, so a repeat means a second source supplied it. +func duplicatedForwardKey(ctx context.Context) bool { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return false + } + for _, key := range forwardKeys { + // Group SIDs are legitimately repeated; the rest identify the caller. + if key == mdFwdGroup { + continue + } + if len(md.Get(key)) > 1 { + return true + } + } + return false +} + +// authenticForward reports whether the request carries this process's forwarding +// proof, which only the in-process JSON gateway can supply. +func authenticForward(ctx context.Context) bool { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return false + } + got := mdSingle(md, mdFwdProof) + return subtle.ConstantTimeCompare([]byte(got), []byte(forwardProof)) == 1 +} + +// isForwarded reports whether the request carries the JSON gateway marker. +func isForwarded(ctx context.Context) bool { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return false + } + return mdSingle(md, mdFwd) != "" +} + +// forwardedIdentity decodes the identity the JSON gateway attached. +func forwardedIdentity(ctx context.Context) (Identity, bool) { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return Identity{}, false + } + + if sid := mdSingle(md, mdFwdSID); sid != "" { + return Identity{ + SID: sid, + // Repeated by design, one value per group, and only reachable once + // the forwarding proof has been verified. + Groups: md.Get(mdFwdGroup), + Elevated: mdSingle(md, mdFwdElevated) == "1", + }, true + } + + uid, err := strconv.ParseUint(mdSingle(md, mdFwdUID), 10, 32) + if err != nil { + return Identity{}, false + } + + id := Identity{UID: uint32(uid)} + if gid, err := strconv.ParseUint(mdSingle(md, mdFwdGID), 10, 32); err == nil { + id.GID = uint32(gid) + } + return id, true +} + +// mdSingle returns the value of a forwarded key only when exactly one was +// supplied. The gateway's interceptor sets each key exactly once, so more than one +// value means something else also supplied it, and the whole identity is treated as +// unknown rather than picking a winner. Defence in depth behind the gateway's +// header filter. +func mdSingle(md metadata.MD, key string) string { + if v := md.Get(key); len(v) == 1 { + return v[0] + } + return "" +} + +// WithForwardedIdentity stamps id onto a context's outgoing metadata for the JSON +// gateway's call to the daemon, replacing any forwarding keys already present so +// values supplied from outside cannot survive alongside it. +// +// This is deliberately not done with runtime.WithMetadata: grpc-gateway skips its +// annotators entirely when no request header maps to metadata ("if len(pairs) == 0 +// { return ctx, nil, nil }", runtime/context.go), which an HTTP/1.0 request with no +// Host header over a unix socket achieves. The daemon would then see an unmarked +// call whose transport peer is the daemon's own identity, and authorize it as the +// daemon. A client interceptor runs for every RPC regardless of headers. +func WithForwardedIdentity(ctx context.Context, id Identity, known bool) context.Context { + md, ok := metadata.FromOutgoingContext(ctx) + if !ok { + md = metadata.MD{} + } else { + md = md.Copy() + } + + for _, key := range forwardKeys { + delete(md, key) + } + for key, values := range ForwardIdentityMetadata(id, known) { + md[key] = values + } + + return metadata.NewOutgoingContext(ctx, md) +} diff --git a/client/internal/ipcauth/forward_test.go b/client/internal/ipcauth/forward_test.go new file mode 100644 index 000000000..d9adf05da --- /dev/null +++ b/client/internal/ipcauth/forward_test.go @@ -0,0 +1,214 @@ +package ipcauth + +import ( + "context" + "testing" + + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/peer" +) + +// transportCtx builds a request context as the daemon's transport credentials +// would: the identity of whoever opened the socket, plus whatever metadata the +// request carried. +func transportCtx(id Identity, md metadata.MD) context.Context { + ctx := peer.NewContext(context.Background(), &peer.Peer{ + AuthInfo: AuthInfo{ + CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity}, + Identity: id, + }, + }) + if md != nil { + ctx = metadata.NewIncomingContext(ctx, md) + } + return ctx +} + +var ( + root = Identity{UID: 0} + unprivUser = Identity{UID: 1000, GID: 1000} +) + +// asDaemon pins which identity counts as this process for the duration of a test. +// Without it the test binary's own uid decides, which silently changes what +// "the gateway" means. +func asDaemon(t *testing.T, id Identity) { + t.Helper() + prevID, prevKnown, prevDelegate := selfIdentity, selfKnown, selfMayDelegate + t.Cleanup(func() { selfIdentity, selfKnown, selfMayDelegate = prevID, prevKnown, prevDelegate }) + selfIdentity, selfKnown = id, true + selfMayDelegate = !id.IsPrivileged() +} + +func TestCallerIdentity_DirectConnections(t *testing.T) { + t.Run("no transport credentials is not an identity", func(t *testing.T) { + if _, ok := CallerIdentity(context.Background()); ok { + t.Fatal("a caller with no credentials must not be identified") + } + }) + + t.Run("a direct caller is its transport identity", func(t *testing.T) { + id, ok := CallerIdentity(transportCtx(unprivUser, nil)) + if !ok || id.UID != 1000 { + t.Fatalf("got %v ok=%t, want uid 1000", id, ok) + } + }) + + // The whole point of honouring forwarded metadata only from a privileged + // transport peer: an unprivileged caller can set any metadata it likes on its + // own connection to the daemon socket. + t.Run("an unprivileged caller cannot forge an identity", func(t *testing.T) { + asDaemon(t, root) + forged := metadata.Pairs(mdFwd, "1", mdFwdUID, "0", mdFwdGID, "0") + id, ok := CallerIdentity(transportCtx(unprivUser, forged)) + if !ok { + t.Fatal("caller should still be identified, as itself") + } + if id.IsPrivileged() || id.UID != 1000 { + t.Fatalf("forged metadata was believed: got %v", id) + } + }) +} + +func TestCallerIdentity_GatewayForwarding(t *testing.T) { + t.Run("the gateway's client identity is used, not the gateway's own", func(t *testing.T) { + asDaemon(t, root) + md := ForwardIdentityMetadata(unprivUser, true) + id, ok := CallerIdentity(transportCtx(root, md)) + if !ok { + t.Fatal("forwarded identity should be usable") + } + if id.IsPrivileged() || id.UID != 1000 { + t.Fatalf("got %v, want the forwarded uid 1000 and not privileged", id) + } + }) + + t.Run("a privileged gateway client stays privileged", func(t *testing.T) { + asDaemon(t, root) + md := ForwardIdentityMetadata(root, true) + id, ok := CallerIdentity(transportCtx(root, md)) + if !ok || !id.IsPrivileged() { + t.Fatalf("got %v ok=%t, want a privileged identity", id, ok) + } + }) + + // A JSON socket the gateway cannot read peer credentials from (a TCP socket, + // say) must not make every request look like the daemon itself. + t.Run("an unreadable client identity is unknown, not the daemon", func(t *testing.T) { + asDaemon(t, root) + md := ForwardIdentityMetadata(Identity{}, false) + if _, ok := CallerIdentity(transportCtx(root, md)); ok { + t.Fatal("a forwarded request with no identity must not be identified") + } + }) + + // grpc-gateway turns Grpc-Metadata- headers into gRPC metadata and joins + // them ahead of its annotators' values. If an HTTP client's header survived + // that, this is the shape the daemon would see: the attacker's uid 0 first, + // the real uid second. The gateway filters those headers out, and reading a + // duplicated key as unknown makes the daemon safe even if it did not. + t.Run("a duplicated key from an injected header is not believed", func(t *testing.T) { + asDaemon(t, root) + md := metadata.MD{} + md.Append(mdFwd, "1") + md.Append(mdFwdUID, "0") // injected by the HTTP client + md.Append(mdFwdUID, "1000") // appended by the gateway's annotator + if id, ok := CallerIdentity(transportCtx(root, md)); ok { + t.Fatalf("injected uid was accepted: got %v", id) + } + }) + + t.Run("a duplicated marker is not believed either", func(t *testing.T) { + asDaemon(t, root) + md := metadata.MD{} + md.Append(mdFwd, "1") + md.Append(mdFwd, "1") + md.Append(mdFwdUID, "1000") + // A repeated marker must not be read as "not forwarded": that would + // authorize the request as the transport peer, which on the gateway's + // connection is the daemon itself. + if id, ok := CallerIdentity(transportCtx(root, md)); ok { + t.Fatalf("a duplicated marker was believed: got %v", id) + } + }) + + // The layers in front of this (the gateway's header matcher, and its + // interceptor replacing every forwarding key) are what keep outside metadata + // from arriving at all. The proof is what the daemon can check for itself, and + // it is the only defence that works for a value whose legitimate shape is + // indistinguishable from an injected one: a lone group SID, or "elevated". + t.Run("forwarding metadata without this process's proof is refused", func(t *testing.T) { + asDaemon(t, root) + for name, md := range map[string]metadata.MD{ + "no proof": metadata.Pairs(mdFwd, "1", mdFwdUID, "0"), + "wrong proof": metadata.Pairs(mdFwd, "1", mdFwdUID, "0", mdFwdProof, "deadbeef"), + "windows identity without a proof": metadata.Pairs(mdFwd, "1", + mdFwdSID, "S-1-5-21-1-2-3-1001", mdFwdGroup, sidAdministrators, mdFwdElevated, "1"), + } { + t.Run(name, func(t *testing.T) { + if id, ok := CallerIdentity(transportCtx(root, md)); ok { + t.Fatalf("unstamped forwarding metadata was believed: got %v", id) + } + }) + } + }) + + // A caller that reaches the gateway cannot see the proof, so it cannot append + // a group of its own to a genuine forwarded identity: doing so would have to + // go through the interceptor, which replaces the whole set. + t.Run("a group appended to a stamped identity does not survive the interceptor", func(t *testing.T) { + asDaemon(t, root) + injected := metadata.MD{} + injected.Append(mdFwdGroup, sidAdministrators) + + ctx := WithForwardedIdentity(metadata.NewOutgoingContext(context.Background(), injected), + Identity{SID: "S-1-5-21-1-2-3-1001"}, true) + out, ok := metadata.FromOutgoingContext(ctx) + if !ok { + t.Fatal("no outgoing metadata") + } + if groups := out.Get(mdFwdGroup); len(groups) != 0 { + t.Fatalf("injected group survived: %v", groups) + } + }) +} + +func TestIsReservedForwardKey(t *testing.T) { + for _, key := range forwardKeys { + if !IsReservedForwardKey(key) { + t.Errorf("%q must be reserved", key) + } + } + + // grpc-gateway canonicalises header names, so the check has to be + // case-insensitive. + if !IsReservedForwardKey("X-Netbird-Fwd-Uid") { + t.Error("the check must be case-insensitive") + } + + for _, key := range []string{"authorization", "x-netbird", "x-netbird-fwd-uid-extra", ""} { + if IsReservedForwardKey(key) { + t.Errorf("%q must not be reserved", key) + } + } +} + +func TestForwardIdentityMetadata_AlwaysMarksForwarded(t *testing.T) { + for _, tc := range []struct { + name string + id Identity + known bool + }{ + {"known unix identity", unprivUser, true}, + {"unknown identity", Identity{}, false}, + {"windows identity", Identity{SID: "S-1-5-21-1-2-3-1001", Elevated: true}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + md := ForwardIdentityMetadata(tc.id, tc.known) + if got := md.Get(mdFwd); len(got) != 1 || got[0] != "1" { + t.Fatalf("marker = %v, want exactly one \"1\"", got) + } + }) + } +} diff --git a/client/internal/ipcauth/identity.go b/client/internal/ipcauth/identity.go new file mode 100644 index 000000000..d7d10f57d --- /dev/null +++ b/client/internal/ipcauth/identity.go @@ -0,0 +1,140 @@ +// Package ipcauth provides the kernel-authenticated identity of a local IPC +// (gRPC) caller and the transport credentials that surface it into the gRPC +// context, so the daemon can authorize individual RPCs by caller identity. +// +// On Unix the identity is read from the kernel via SO_PEERCRED (Linux) or +// LOCAL_PEERCRED (Darwin/FreeBSD). On Windows it is derived from the +// named-pipe client token. Platforms without a peer-identity primitive get no +// credentials, and every consumer must fail closed when no identity is +// available. +package ipcauth + +import ( + "context" + "fmt" + "slices" + + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/peer" +) + +// Well-known Windows SIDs that identify a fully privileged principal. +const ( + sidLocalSystem = "S-1-5-18" // NT AUTHORITY\SYSTEM + sidLocalService = "S-1-5-19" // NT AUTHORITY\LOCAL SERVICE + sidNetworkService = "S-1-5-20" // NT AUTHORITY\NETWORK SERVICE + sidAdministrators = "S-1-5-32-544" // BUILTIN\Administrators +) + +// Identity is the kernel-authenticated identity of a local IPC caller. The +// zero value is not a valid identity: consumers must only use one obtained +// with a true ok/nil error return. +type Identity struct { + // UID and GID are the caller's Unix user ID and primary group ID. Both are + // zero on Windows, where SID is authoritative instead. + UID uint32 + GID uint32 + + // SID is the caller's Windows security identifier, empty on Unix. + SID string + + // Groups holds the caller's Windows group SIDs, captured from the client + // token at handshake time. Only groups that are enabled and not + // deny-only are captured, so a group listed here is one the caller can + // actually exercise. Empty on Unix. + Groups []string + + // Elevated reports whether the Windows client token is elevated (running + // as administrator, or an administrator with UAC turned off). Always false + // on Unix, where privilege is uid 0. + Elevated bool + + // PID is the caller's process ID where the platform reports it (Linux's + // SO_PEERCRED), and 0 where it does not. It identifies the daemon's own + // process dialling itself, which is what the JSON gateway does, and is never + // used to grant anything. + PID int32 +} + +// IsWindows reports whether this identity is a Windows principal (SID-based) +// rather than a Unix uid/gid principal. +func (i Identity) IsWindows() bool { + return i.SID != "" +} + +// IsPrivileged reports whether the caller is the platform's administrative +// principal, which is what the daemon requires for changes that cross the +// user-to-root boundary. +// +// On Windows the decision comes from the caller's token rather than from +// account names or group RIDs: an elevated token, one of the service accounts +// the daemon itself may run as, or a token with BUILTIN\Administrators +// enabled. A UAC-filtered administrator has that group marked deny-only, and +// deny-only groups are dropped when the identity is captured, so such a +// caller is correctly reported as unprivileged. Domain group memberships +// (Domain Admins and friends) are deliberately not consulted: they say +// nothing about what this token may do on this machine. +func (i Identity) IsPrivileged() bool { + if !i.IsWindows() { + return i.UID == 0 + } + + if i.Elevated { + return true + } + + switch i.SID { + case sidLocalSystem, sidLocalService, sidNetworkService: + return true + } + + return slices.Contains(i.Groups, sidAdministrators) +} + +// SameUser reports whether two identities are the same local principal. Only +// the account is compared: the group set and the elevation flag describe what a +// token may do, not who it belongs to. A SID on either side decides the +// comparison, so a Windows principal never matches a Unix one on the UID both +// happen to leave at zero. The zero Identity carries uid 0, so callers must +// establish that both identities are real before the answer means anything. +func (i Identity) SameUser(other Identity) bool { + if i.SID != "" || other.SID != "" { + return i.SID == other.SID + } + return i.UID == other.UID +} + +// String renders the identity for audit logs and denial messages. +func (i Identity) String() string { + if i.IsWindows() { + return fmt.Sprintf("sid=%s elevated=%t", i.SID, i.Elevated) + } + return fmt.Sprintf("uid=%d gid=%d", i.UID, i.GID) +} + +// AuthInfo carries the peer Identity as a gRPC credentials.AuthInfo so +// handlers can retrieve it from the request context via IdentityFromContext. +type AuthInfo struct { + credentials.CommonAuthInfo + Identity Identity +} + +// AuthType identifies the authentication scheme. +func (AuthInfo) AuthType() string { return "netbird-ipc-peercred" } + +// IdentityFromContext extracts the caller's kernel-authenticated identity from +// the gRPC peer context. The second return value is false when no IPC +// transport credentials were negotiated, which happens on a TCP daemon socket +// and on platforms without a peer-identity primitive. Callers MUST fail closed +// in that case. +func IdentityFromContext(ctx context.Context) (Identity, bool) { + p, ok := peer.FromContext(ctx) + if !ok { + return Identity{}, false + } + info, ok := p.AuthInfo.(AuthInfo) + if !ok { + return Identity{}, false + } + return info.Identity, true +} diff --git a/client/internal/ipcauth/identity_sameuser_test.go b/client/internal/ipcauth/identity_sameuser_test.go new file mode 100644 index 000000000..c98f583db --- /dev/null +++ b/client/internal/ipcauth/identity_sameuser_test.go @@ -0,0 +1,66 @@ +package ipcauth + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIdentitySameUser(t *testing.T) { + tests := []struct { + name string + a Identity + b Identity + want bool + }{ + { + name: "same uid", + a: Identity{UID: 1000, GID: 1000}, + b: Identity{UID: 1000, GID: 1000}, + want: true, + }, + { + name: "same uid, different gid and pid still the same user", + a: Identity{UID: 1000, GID: 1000, PID: 11}, + b: Identity{UID: 1000, GID: 27, PID: 22}, + want: true, + }, + { + name: "different uid", + a: Identity{UID: 1000}, + b: Identity{UID: 1001}, + want: false, + }, + { + name: "same sid", + a: Identity{SID: "S-1-5-21-1-2-3-1001"}, + b: Identity{SID: "S-1-5-21-1-2-3-1001"}, + want: true, + }, + { + name: "same sid, elevation and groups differ", + a: Identity{SID: "S-1-5-21-1-2-3-1001", Elevated: true, Groups: []string{sidAdministrators}}, + b: Identity{SID: "S-1-5-21-1-2-3-1001"}, + want: true, + }, + { + name: "different sid", + a: Identity{SID: "S-1-5-21-1-2-3-1001"}, + b: Identity{SID: "S-1-5-21-1-2-3-1002"}, + want: false, + }, + { + name: "a windows principal is never a unix one", + a: Identity{SID: "S-1-5-18"}, + b: Identity{UID: 0}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.a.SameUser(tt.b)) + assert.Equal(t, tt.want, tt.b.SameUser(tt.a), "SameUser must be symmetric") + }) + } +} diff --git a/client/internal/ipcauth/ownedfile.go b/client/internal/ipcauth/ownedfile.go new file mode 100644 index 000000000..be7bf4864 --- /dev/null +++ b/client/internal/ipcauth/ownedfile.go @@ -0,0 +1,63 @@ +package ipcauth + +import ( + "fmt" + "os" +) + +// OpenOwnedFile opens path for reading on behalf of the IPC caller identified by +// id, and fails unless the opened file is a regular file that id owns. +// +// It exists for the paths a local caller hands to the daemon over the IPC. The +// daemon runs as root, so opening such a path unchecked lets any local user read +// any file through it. Ownership is the invariant that keeps the daemon from +// reading, with its own privileges, a file the caller could not read itself: a +// symlink or hard link planted at the path resolves to a file someone else owns +// and is refused. +// +// The check is made against the open descriptor rather than the path, so +// swapping the path between the check and the read cannot change the answer. +// +// A privileged caller is exempt: it can read the file directly, so refusing it +// here would protect nothing. The regular-file requirement still applies to +// everyone, since a fifo or device planted at the path is never a log file. +func OpenOwnedFile(id Identity, path string) (*os.File, error) { + f, err := openForRead(path) + if err != nil { + return nil, err + } + + if err := checkOwnership(id, f); err != nil { + if cerr := f.Close(); cerr != nil { + return nil, fmt.Errorf("%w (close: %v)", err, cerr) + } + return nil, err + } + + return f, nil +} + +func checkOwnership(id Identity, f *os.File) error { + info, err := f.Stat() + if err != nil { + return fmt.Errorf("stat %s: %w", f.Name(), err) + } + + if !info.Mode().IsRegular() { + return fmt.Errorf("%s is not a regular file", f.Name()) + } + + if IsPrivilegedCaller(id) { + return nil + } + + owned, err := fileOwnedBy(id, f) + if err != nil { + return fmt.Errorf("read owner of %s: %w", f.Name(), err) + } + if !owned { + return fmt.Errorf("%s is not owned by the caller (%s)", f.Name(), id) + } + + return nil +} diff --git a/client/internal/ipcauth/ownedfile_test.go b/client/internal/ipcauth/ownedfile_test.go new file mode 100644 index 000000000..b8178ad45 --- /dev/null +++ b/client/internal/ipcauth/ownedfile_test.go @@ -0,0 +1,64 @@ +package ipcauth + +import ( + "io" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/require" +) + +// otherIdentity is an unprivileged caller that owns nothing the test creates. +func otherIdentity(t *testing.T) Identity { + t.Helper() + if runtime.GOOS == "windows" { + return Identity{SID: "S-1-5-21-1-2-3-1001"} + } + return Identity{UID: uint32(os.Geteuid() + 1), GID: uint32(os.Getegid() + 1)} +} + +func TestOpenOwnedFileReadsFileOwnedByCaller(t *testing.T) { + path := filepath.Join(t.TempDir(), "gui-client.log") + require.NoError(t, os.WriteFile(path, []byte("hello"), 0600)) + + id, err := CurrentProcessIdentity() + require.NoError(t, err) + + f, err := OpenOwnedFile(id, path) + require.NoError(t, err) + t.Cleanup(func() { _ = f.Close() }) + + content, err := io.ReadAll(f) + require.NoError(t, err) + require.Equal(t, "hello", string(content)) +} + +func TestOpenOwnedFileRefusesFileOwnedByAnother(t *testing.T) { + path := filepath.Join(t.TempDir(), "gui-client.log") + require.NoError(t, os.WriteFile(path, []byte("secret"), 0600)) + + _, err := OpenOwnedFile(otherIdentity(t), path) + require.ErrorContains(t, err, "not owned by the caller") +} + +func TestOpenOwnedFileRefusesNonRegularFile(t *testing.T) { + dir := t.TempDir() + + // The caller owns the directory, so this is the regular-file requirement + // talking, not the ownership check. + id, err := CurrentProcessIdentity() + require.NoError(t, err) + + _, err = OpenOwnedFile(id, dir) + require.ErrorContains(t, err, "not a regular file") +} + +func TestOpenOwnedFileRefusesMissingFile(t *testing.T) { + id, err := CurrentProcessIdentity() + require.NoError(t, err) + + _, err = OpenOwnedFile(id, filepath.Join(t.TempDir(), "absent.log")) + require.Error(t, err) +} diff --git a/client/internal/ipcauth/ownedfile_unix.go b/client/internal/ipcauth/ownedfile_unix.go new file mode 100644 index 000000000..4a6afcea7 --- /dev/null +++ b/client/internal/ipcauth/ownedfile_unix.go @@ -0,0 +1,35 @@ +//go:build !windows + +package ipcauth + +import ( + "fmt" + "os" + "syscall" +) + +// openForRead opens a caller-supplied path without following a symlink at its +// final component and without blocking: a fifo planted at the path would +// otherwise stall the open until a writer appears, and the daemon holds a lock +// while it collects the file. +func openForRead(path string) (*os.File, error) { + f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) + if err != nil { + return nil, fmt.Errorf("open %s: %w", path, err) + } + return f, nil +} + +func fileOwnedBy(id Identity, f *os.File) (bool, error) { + info, err := f.Stat() + if err != nil { + return false, err + } + + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return false, fmt.Errorf("no owner information in %T", info.Sys()) + } + + return stat.Uid == id.UID, nil +} diff --git a/client/internal/ipcauth/ownedfile_unix_test.go b/client/internal/ipcauth/ownedfile_unix_test.go new file mode 100644 index 000000000..9e7831991 --- /dev/null +++ b/client/internal/ipcauth/ownedfile_unix_test.go @@ -0,0 +1,57 @@ +//go:build !windows + +package ipcauth + +import ( + "errors" + "os" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// A symlink is the shape the arbitrary-read attempt takes: the caller owns the +// link, the file it points at belongs to someone else. +func TestOpenOwnedFileRefusesSymlink(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target.log") + require.NoError(t, os.WriteFile(target, []byte("secret"), 0600)) + + link := filepath.Join(dir, "gui-client.log") + require.NoError(t, os.Symlink(target, link)) + + id, err := CurrentProcessIdentity() + require.NoError(t, err) + + _, err = OpenOwnedFile(id, link) + // O_NOFOLLOW on a symlink reports ELOOP on Linux/Darwin and EMLINK on FreeBSD. + if !errors.Is(err, syscall.ELOOP) && !errors.Is(err, syscall.EMLINK) { + t.Fatalf("symlink open: got %v, want ELOOP or EMLINK", err) + } +} + +// A fifo would block the open until a writer showed up, stalling the daemon +// while it holds its lock. +func TestOpenOwnedFileRefusesFifoWithoutBlocking(t *testing.T) { + path := filepath.Join(t.TempDir(), "gui-client.log") + require.NoError(t, syscall.Mkfifo(path, 0600)) + + id, err := CurrentProcessIdentity() + require.NoError(t, err) + + done := make(chan error, 1) + go func() { + _, err := OpenOwnedFile(id, path) + done <- err + }() + + select { + case err := <-done: + require.ErrorContains(t, err, "not a regular file") + case <-time.After(5 * time.Second): + t.Fatal("opening a fifo blocked") + } +} diff --git a/client/internal/ipcauth/ownedfile_windows.go b/client/internal/ipcauth/ownedfile_windows.go new file mode 100644 index 000000000..19acaec4d --- /dev/null +++ b/client/internal/ipcauth/ownedfile_windows.go @@ -0,0 +1,59 @@ +//go:build windows + +package ipcauth + +import ( + "fmt" + "os" + + "golang.org/x/sys/windows" +) + +// openForRead opens a caller-supplied path without following a reparse point at +// it. FILE_FLAG_OPEN_REPARSE_POINT is the Windows analogue of O_NOFOLLOW: it +// opens a symlink/junction itself rather than its target, so the regular-file +// check in checkOwnership refuses a link the caller planted to redirect the +// read. FILE_FLAG_BACKUP_SEMANTICS lets a directory open too (as os.Open does), +// so a directory planted at the path is refused as non-regular rather than +// erroring here. The share mode matches os.Open so a log being written stays +// openable. +func openForRead(path string) (*os.File, error) { + p, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, fmt.Errorf("convert path %s: %w", path, err) + } + + handle, err := windows.CreateFile( + p, + windows.GENERIC_READ, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_EXISTING, + windows.FILE_FLAG_OPEN_REPARSE_POINT|windows.FILE_FLAG_BACKUP_SEMANTICS, + 0, + ) + if err != nil { + return nil, fmt.Errorf("open %s: %w", path, err) + } + + return os.NewFile(uintptr(handle), path), nil +} + +// fileOwnedBy compares the file's owner SID with the caller's. Files an elevated +// process creates are owned by BUILTIN\Administrators rather than by the user, +// but such a caller is privileged and never reaches this check. +func fileOwnedBy(id Identity, f *os.File) (bool, error) { + // x/sys/windows GetSecurityInfo frees the OS buffer itself and returns a + // Go-heap copy, so there is nothing to LocalFree here. + sd, err := windows.GetSecurityInfo(windows.Handle(f.Fd()), windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION) + if err != nil { + return false, fmt.Errorf("read security info: %w", err) + } + + owner, _, err := sd.Owner() + if err != nil { + return false, fmt.Errorf("read owner: %w", err) + } + + return id.SID != "" && owner.String() == id.SID, nil +} diff --git a/client/internal/ipcauth/ownedfile_windows_test.go b/client/internal/ipcauth/ownedfile_windows_test.go new file mode 100644 index 000000000..ab68fdf39 --- /dev/null +++ b/client/internal/ipcauth/ownedfile_windows_test.go @@ -0,0 +1,78 @@ +//go:build windows + +package ipcauth + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +// fileOwnerSID reads the owner SID of path the same way OpenOwnedFile does, so +// the test can construct an Identity that matches (or deliberately does not). +func fileOwnerSID(t *testing.T, path string) string { + t.Helper() + f, err := os.Open(path) + require.NoError(t, err) + t.Cleanup(func() { _ = f.Close() }) + + sd, err := windows.GetSecurityInfo(windows.Handle(f.Fd()), windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION) + require.NoError(t, err) + owner, _, err := sd.Owner() + require.NoError(t, err) + return owner.String() +} + +// The allow branch of fileOwnedBy is the SID-equality path the legitimate GUI +// flow depends on. Running elevated, a created file is owned by +// BUILTIN\Administrators; an Identity carrying that SID with Elevated=false and +// no groups is unprivileged by IsPrivileged (which reads the token, not the +// SID's RID), so this exercises the real GetSecurityInfo equality rather than +// the privileged-caller shortcut. +func TestOpenOwnedFileWindowsOwnerMatchAllows(t *testing.T) { + path := filepath.Join(t.TempDir(), "gui-client.log") + require.NoError(t, os.WriteFile(path, []byte("hello"), 0600)) + + ownerSID := fileOwnerSID(t, path) + id := Identity{SID: ownerSID} + require.False(t, id.IsPrivileged(), "identity built from the owner SID must be unprivileged for this to test the match path") + + f, err := OpenOwnedFile(id, path) + require.NoError(t, err) + _ = f.Close() +} + +func TestOpenOwnedFileWindowsOwnerMismatchRefuses(t *testing.T) { + path := filepath.Join(t.TempDir(), "gui-client.log") + require.NoError(t, os.WriteFile(path, []byte("secret"), 0600)) + + other := Identity{SID: "S-1-5-21-9-9-9-9999"} + require.False(t, other.IsPrivileged()) + + _, err := OpenOwnedFile(other, path) + require.ErrorContains(t, err, "not owned by the caller") +} + +// FILE_FLAG_OPEN_REPARSE_POINT must make OpenOwnedFile refuse a symlink the same +// way O_NOFOLLOW does on Unix, so a planted link can't redirect the read to +// another file. Creating a symlink needs a privilege the runner may lack, so the +// test skips rather than fails when it can't. +func TestOpenOwnedFileWindowsRefusesSymlink(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target.log") + require.NoError(t, os.WriteFile(target, []byte("secret"), 0600)) + + link := filepath.Join(dir, "gui-client.log") + if err := os.Symlink(target, link); err != nil { + t.Skipf("cannot create symlink (privilege not held?): %v", err) + } + + id, err := CurrentProcessIdentity() + require.NoError(t, err) + + _, err = OpenOwnedFile(id, link) + require.Error(t, err, "a symlink must be refused") +} diff --git a/client/internal/ipcauth/peercred_bsd.go b/client/internal/ipcauth/peercred_bsd.go new file mode 100644 index 000000000..6d9c5247f --- /dev/null +++ b/client/internal/ipcauth/peercred_bsd.go @@ -0,0 +1,43 @@ +//go:build darwin || freebsd + +package ipcauth + +import ( + "fmt" + "net" + + "golang.org/x/sys/unix" +) + +// PeerIdentity reads the kernel-authenticated identity of the process on the +// other end of a Unix socket via LOCAL_PEERCRED. The xucred is recorded by the +// kernel at connect() time and carries the peer's uid and its group list, of +// which the first entry is the primary group. +func PeerIdentity(conn net.Conn) (Identity, error) { + uc, ok := conn.(*net.UnixConn) + if !ok { + return Identity{}, fmt.Errorf("connection is not a unix socket: %T", conn) + } + + raw, err := uc.SyscallConn() + if err != nil { + return Identity{}, fmt.Errorf("raw conn: %w", err) + } + + var cred *unix.Xucred + var credErr error + if err := raw.Control(func(fd uintptr) { + cred, credErr = unix.GetsockoptXucred(int(fd), unix.SOL_LOCAL, unix.LOCAL_PEERCRED) + }); err != nil { + return Identity{}, fmt.Errorf("control raw conn: %w", err) + } + if credErr != nil { + return Identity{}, fmt.Errorf("read LOCAL_PEERCRED: %w", credErr) + } + + id := Identity{UID: cred.Uid} + if cred.Ngroups > 0 { + id.GID = cred.Groups[0] + } + return id, nil +} diff --git a/client/internal/ipcauth/peercred_linux.go b/client/internal/ipcauth/peercred_linux.go new file mode 100644 index 000000000..417cc1e00 --- /dev/null +++ b/client/internal/ipcauth/peercred_linux.go @@ -0,0 +1,39 @@ +//go:build linux + +package ipcauth + +import ( + "fmt" + "net" + + "golang.org/x/sys/unix" +) + +// PeerIdentity reads the kernel-authenticated identity of the process on the +// other end of a Unix socket via SO_PEERCRED. The credentials are recorded by +// the kernel at connect() time and cannot be changed for the life of the +// connection, so they are not spoofable by the caller. +func PeerIdentity(conn net.Conn) (Identity, error) { + uc, ok := conn.(*net.UnixConn) + if !ok { + return Identity{}, fmt.Errorf("connection is not a unix socket: %T", conn) + } + + raw, err := uc.SyscallConn() + if err != nil { + return Identity{}, fmt.Errorf("raw conn: %w", err) + } + + var cred *unix.Ucred + var credErr error + if err := raw.Control(func(fd uintptr) { + cred, credErr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED) + }); err != nil { + return Identity{}, fmt.Errorf("control raw conn: %w", err) + } + if credErr != nil { + return Identity{}, fmt.Errorf("read SO_PEERCRED: %w", credErr) + } + + return Identity{UID: cred.Uid, GID: cred.Gid, PID: cred.Pid}, nil +} diff --git a/client/internal/ipcauth/pipeserver_windows.go b/client/internal/ipcauth/pipeserver_windows.go new file mode 100644 index 000000000..7ba59d574 --- /dev/null +++ b/client/internal/ipcauth/pipeserver_windows.go @@ -0,0 +1,87 @@ +//go:build windows + +package ipcauth + +import ( + "fmt" + "net" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" +) + +// PipeServerTrusted reports an error unless the pipe behind conn was created by a +// principal this client may hand secrets to. Clients call it for a pipe whose name +// carries no guarantee of its own, which is any name outside the +// ProtectedPrefix\Administrators namespace: that namespace already restricts +// creation to administrators and LocalSystem, while a plain name can be created by +// any local user before the daemon gets there. +// +// The decision is made from the pipe object's owner, not from the serving process, +// because a client cannot open a process running as another user at all, and the +// legitimate case is precisely an unprivileged client talking to a privileged +// daemon. Trusted owners are the service accounts, BUILTIN\Administrators, and +// this client's own user, the last of which is the daemon a user runs themselves +// as in netstack mode. A pipe owned by anyone else gets no setup key, pre-shared +// key or SSO prompt out of this client. +func PipeServerTrusted(conn net.Conn) error { + // go-winio's pipe connection embeds *win32File, which exposes Fd(). + fdConn, ok := conn.(interface{ Fd() uintptr }) + if !ok { + return fmt.Errorf("connection %T does not expose a pipe handle", conn) + } + + owner, err := pipeOwnerSID(windows.Handle(fdConn.Fd())) + if err != nil { + return err + } + + if !trustedPipeOwner(owner) { + return fmt.Errorf("pipe owned by %s, which is neither an administrator nor this user", owner) + } + return nil +} + +// PipeOwnedBySelf reports whether the pipe behind conn was created by this very +// user, which is how a client recognises a daemon running as itself. Ownership it +// cannot read is reported as false. +func PipeOwnedBySelf(conn net.Conn) bool { + fdConn, ok := conn.(interface{ Fd() uintptr }) + if !ok { + return false + } + + owner, err := pipeOwnerSID(windows.Handle(fdConn.Fd())) + if err != nil { + log.Debugf("read daemon pipe owner: %v", err) + return false + } + return selfKnown && selfIdentity.SID != "" && owner == selfIdentity.SID +} + +// pipeOwnerSID reads the owner of the pipe object a client is connected to. The +// handle was opened with GENERIC_READ, which includes READ_CONTROL, so no extra +// access is needed. +func pipeOwnerSID(handle windows.Handle) (string, error) { + sd, err := windows.GetSecurityInfo(handle, windows.SE_KERNEL_OBJECT, windows.OWNER_SECURITY_INFORMATION) + if err != nil { + return "", fmt.Errorf("read pipe security info: %w", err) + } + + owner, _, err := sd.Owner() + if err != nil { + return "", fmt.Errorf("read pipe owner: %w", err) + } + return owner.String(), nil +} + +// trustedPipeOwner reports whether a pipe's owner is a principal a client may +// speak to. An elevated process's objects are owned by BUILTIN\Administrators by +// default, an unelevated one's by the user, which is why both forms appear here. +func trustedPipeOwner(owner string) bool { + switch owner { + case sidLocalSystem, sidLocalService, sidNetworkService, sidAdministrators: + return true + } + return selfKnown && selfIdentity.SID != "" && owner == selfIdentity.SID +} diff --git a/client/internal/ipcauth/privileged.go b/client/internal/ipcauth/privileged.go new file mode 100644 index 000000000..3c2e68432 --- /dev/null +++ b/client/internal/ipcauth/privileged.go @@ -0,0 +1,141 @@ +package ipcauth + +import ( + "os" + "runtime" +) + +// Fields of the ErrorInfo detail the daemon attaches to a PermissionDenied it +// raises for an operation that requires root/administrator. Clients match on +// Reason and Domain rather than on the message text, and render the summary and +// command themselves so the user gets guidance instead of a gRPC error dump. +const ( + // ErrorReasonPrivilegeRequired identifies the detail. + ErrorReasonPrivilegeRequired = "PRIVILEGE_REQUIRED" + // ErrorDomain scopes the reason to the NetBird daemon. + ErrorDomain = "daemon.netbird.io" + // ErrorMetaSummary is the one-sentence explanation of what was refused. + ErrorMetaSummary = "summary" + // ErrorMetaCommand is the command that performs the same operation with the + // privileges it needs, ready to copy and run. + ErrorMetaCommand = "command" +) + +// The identity of the process evaluating callers, captured once because it cannot +// change. selfKnown is false when it could not be read, in which case nothing is +// ever treated as this process. selfMayDelegate additionally requires this +// process to be unprivileged: see IsPrivilegedCaller. +var ( + selfIdentity Identity + selfKnown bool + selfMayDelegate bool + // selfPID is this process's PID, used to recognise the daemon dialling itself. + selfPID = os.Getpid() +) + +func init() { + id, err := CurrentProcessIdentity() + if err != nil { + return + } + selfIdentity, selfKnown = id, true + // Only an unprivileged daemon delegates its authority to its own identity. + // When it is root or LocalSystem, sharing its identity does not mean sharing + // its power: on Windows a filtered and a full token carry the same SID, so + // matching there would let a non-elevated shell of an administrator account + // act as an administrator, which is the boundary the token check exists to + // keep. + selfMayDelegate = !id.IsPrivileged() +} + +// IsDaemonSelf reports whether an identity is this very process. The JSON gateway +// runs inside the daemon and re-dials it locally, so this is what distinguishes +// the gateway from any other caller, whatever user the daemon runs as. +func IsDaemonSelf(id Identity) bool { + if !selfKnown || id.IsWindows() != selfIdentity.IsWindows() { + return false + } + if id.IsWindows() { + return id.SID != "" && id.SID == selfIdentity.SID + } + return id.UID == selfIdentity.UID +} + +// IsPrivilegedCaller reports whether an identity may make the changes the daemon +// restricts to the platform administrator. This is the daemon's own rule and +// cannot be evaluated by a client, which does not know what the daemon runs as. +// +// Beyond root/administrator it accepts a caller running as the daemon's own +// identity when the daemon is itself unprivileged. That keeps a rootless container +// working, where there is no uid 0 at all, and a Windows daemon in netstack mode, +// which needs no administrator rights. In those setups a caller sharing the +// daemon's identity can already rewrite the config files it reads and replace the +// binary it runs, so refusing it a config change would protect nothing; and an +// unprivileged daemon cannot hand out a root shell in the first place. +func IsPrivilegedCaller(id Identity) bool { + if id.IsPrivileged() { + return true + } + return selfMayDelegate && IsDaemonSelf(id) +} + +// SelfDelegatesTo returns the identity this process delegates its authority to, +// and whether it delegates at all. Only an unprivileged daemon does: see +// IsPrivilegedCaller. It exists so a refusal can name who may actually perform the +// operation, because on such a host root is neither required nor necessarily +// available. +func SelfDelegatesTo() (Identity, bool) { + if !selfKnown || !selfMayDelegate { + return Identity{}, false + } + return selfIdentity, true +} + +// The values PrivilegedActorKey returns. +const ( + ActorKeyAdministrator = "administrator" + ActorKeyRoot = "root" +) + +// PrivilegedActor names the principal a privileged operation requires, for use +// in messages shown to the user. +func PrivilegedActor() string { + if runtime.GOOS == "windows" { + return "administrator privileges" + } + return "root" +} + +// PrivilegedActorKey identifies that principal without wording it, for a client +// that writes its own message in the user's language. The words PrivilegedActor +// returns are English, and a translated sentence cannot borrow them. +func PrivilegedActorKey() string { + if runtime.GOOS == "windows" { + return ActorKeyAdministrator + } + return ActorKeyRoot +} + +// ElevatedCommand renders a command so that running it grants the privileges the +// operation needs. Windows has no in-line equivalent of sudo, so the command is +// returned unchanged and the user is expected to run it from an elevated +// terminal. +func ElevatedCommand(command string) string { + if runtime.GOOS == "windows" { + return command + } + return "sudo " + command +} + +// UpCommand renders an elevated `netbird up` with the given flags, preceded by a +// `down`. The down is what makes the command work on a connected client: `netbird +// up` prints "Already connected" and returns without applying any config flag, so +// on its own the command would appear to do nothing. It is a no-op, exit 0, when +// the client is not connected. +// +// ";" rather than "&&" so the line can be pasted into any of the shells a user +// might have: PowerShell 5.1, still the default on Windows Server, rejects "&&" +// as a syntax error. +func UpCommand(flags string) string { + return ElevatedCommand("netbird down") + "; " + ElevatedCommand("netbird up "+flags) +} diff --git a/client/internal/ipcauth/privileged_test.go b/client/internal/ipcauth/privileged_test.go new file mode 100644 index 000000000..c1c7c1543 --- /dev/null +++ b/client/internal/ipcauth/privileged_test.go @@ -0,0 +1,134 @@ +package ipcauth + +import "testing" + +// The self rule is the one place privilege is granted to something other than the +// platform administrator, so its two guards matter: it must apply only when the +// daemon is itself unprivileged, and only to a caller with the daemon's identity. +func TestIsPrivilegedCaller_SelfRule(t *testing.T) { + tests := []struct { + name string + // self stands in for the process the daemon runs as. + self Identity + selfKnown bool + caller Identity + want bool + }{ + { + name: "root is privileged whatever the daemon runs as", + self: Identity{UID: 1000}, + selfKnown: true, + caller: Identity{UID: 0}, + want: true, + }, + { + name: "an unprivileged daemon delegates to its own user (rootless container)", + self: Identity{UID: 1000}, + selfKnown: true, + caller: Identity{UID: 1000}, + want: true, + }, + { + name: "an unprivileged daemon delegates to nobody else", + self: Identity{UID: 1000}, + selfKnown: true, + caller: Identity{UID: 1001}, + want: false, + }, + { + // The daemon is root on a normal install, so sharing its identity is + // already covered by being root; nothing else may match. + name: "a root daemon delegates to nobody", + self: Identity{UID: 0}, + selfKnown: true, + caller: Identity{UID: 1000}, + want: false, + }, + { + // Windows netstack mode: the daemon needs no administrator rights. + name: "an unprivileged windows daemon delegates to its own SID", + self: Identity{SID: "S-1-5-21-1-2-3-1001"}, + selfKnown: true, + caller: Identity{SID: "S-1-5-21-1-2-3-1001"}, + want: true, + }, + { + name: "an unprivileged windows daemon delegates to no other SID", + self: Identity{SID: "S-1-5-21-1-2-3-1001"}, + selfKnown: true, + caller: Identity{SID: "S-1-5-21-1-2-3-1002"}, + want: false, + }, + { + // The UAC boundary: a filtered and a full token of the same account + // carry the same SID but not the same power, so an elevated daemon must + // never delegate to its own SID. + name: "an elevated windows daemon does not delegate to its own SID", + self: Identity{SID: "S-1-5-21-1-2-3-500", Elevated: true}, + selfKnown: true, + caller: Identity{SID: "S-1-5-21-1-2-3-500"}, + want: false, + }, + { + name: "LocalSystem is privileged on its own merits, not by delegation", + self: Identity{SID: sidLocalSystem}, + selfKnown: true, + caller: Identity{SID: sidLocalSystem}, + want: true, // LocalSystem is privileged on its own merits + }, + { + name: "identities of different kinds never match", + self: Identity{UID: 1000}, + selfKnown: true, + caller: Identity{SID: "S-1-5-21-1-2-3-1001"}, + want: false, + }, + { + name: "an unknown self identity delegates to nobody", + self: Identity{}, + selfKnown: false, + caller: Identity{UID: 1000}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prevID, prevKnown, prevDelegate := selfIdentity, selfKnown, selfMayDelegate + t.Cleanup(func() { selfIdentity, selfKnown, selfMayDelegate = prevID, prevKnown, prevDelegate }) + + selfIdentity, selfKnown = tt.self, tt.selfKnown + selfMayDelegate = tt.selfKnown && !tt.self.IsPrivileged() + + if got := IsPrivilegedCaller(tt.caller); got != tt.want { + t.Fatalf("IsPrivilegedCaller(%v) with daemon %v = %t, want %t", + tt.caller, tt.self, got, tt.want) + } + }) + } +} + +// The real process must never accidentally delegate: a test binary running as a +// normal user is unprivileged, so it may match itself, but nothing else. +func TestIsPrivilegedCaller_ThisProcess(t *testing.T) { + id, err := CurrentProcessIdentity() + if err != nil { + t.Skipf("cannot read this process's identity: %v", err) + } + + // This process is always allowed to act as itself: either it is privileged, or + // it is unprivileged and therefore delegates to its own identity. + if !IsPrivilegedCaller(id) { + t.Errorf("this process %v was refused its own identity", id) + } + + // A caller that is neither root nor this process must be refused, whatever + // this process happens to be. + other := Identity{UID: id.UID + 1} + if id.IsWindows() { + other = Identity{SID: id.SID + "9"} + } + if IsPrivilegedCaller(other) { + t.Errorf("an unrelated identity %v was treated as privileged", other) + } +} diff --git a/client/internal/ipcauth/self_unix.go b/client/internal/ipcauth/self_unix.go new file mode 100644 index 000000000..1b86c4fc0 --- /dev/null +++ b/client/internal/ipcauth/self_unix.go @@ -0,0 +1,17 @@ +//go:build !windows + +package ipcauth + +import "os" + +// CurrentProcessIdentity returns this process's identity as the daemon would +// see it if this process connected to the local IPC. It lets a client (the UI) +// decide up front whether a privileged operation can succeed, without a +// round-trip and without duplicating the rules: the answer comes from the same +// Identity.IsPrivileged the daemon applies. +func CurrentProcessIdentity() (Identity, error) { + return Identity{ + UID: uint32(os.Geteuid()), + GID: uint32(os.Getegid()), + }, nil +} diff --git a/client/internal/ipcauth/self_windows.go b/client/internal/ipcauth/self_windows.go new file mode 100644 index 000000000..5474cc101 --- /dev/null +++ b/client/internal/ipcauth/self_windows.go @@ -0,0 +1,35 @@ +//go:build windows + +package ipcauth + +import ( + "fmt" + + "golang.org/x/sys/windows" +) + +// CurrentProcessIdentity returns this process's identity as the daemon would see +// it if this process connected to the local IPC. It lets a client (the UI) +// decide up front whether a privileged operation can succeed, without a +// round-trip and without duplicating the rules: the answer comes from the same +// Identity.IsPrivileged the daemon applies to the token it reads off the pipe. +func CurrentProcessIdentity() (Identity, error) { + // A pseudo-token, so it must not be closed. + token := windows.GetCurrentProcessToken() + + user, err := token.GetTokenUser() + if err != nil { + return Identity{}, fmt.Errorf("read token user: %w", err) + } + + groups, err := tokenGroupSIDs(token) + if err != nil { + return Identity{}, err + } + + return Identity{ + SID: user.User.Sid.String(), + Groups: groups, + Elevated: token.IsElevated(), + }, nil +} diff --git a/client/internal/lazyconn/manager/manager.go b/client/internal/lazyconn/manager/manager.go index 3868e37e8..b7424bb2f 100644 --- a/client/internal/lazyconn/manager/manager.go +++ b/client/internal/lazyconn/manager/manager.go @@ -29,6 +29,11 @@ type managedPeer struct { type Config struct { InactivityThreshold *time.Duration + // ReconcileAllowedIPs re-applies a peer's routed allowed IPs after its wake endpoint is + // armed. The activity listener creates the wake peer with the overlay /32 only; without the + // routed prefixes WireGuard would not steer subnet-bound traffic to the wake endpoint, so an + // idle routing peer could never be woken by that traffic. Optional; nil disables the reconcile. + ReconcileAllowedIPs func(peerKey string) error } // Manager manages lazy connections @@ -56,6 +61,9 @@ type Manager struct { peerToHAGroups map[string][]route.HAUniqueID // peer ID -> HA groups they belong to haGroupToPeers map[route.HAUniqueID][]string // HA group -> peer IDs in the group routesMu sync.RWMutex + + // reconcileAllowedIPs re-applies a peer's routed allowed IPs after its wake endpoint is armed. + reconcileAllowedIPs func(peerKey string) error } // NewManager creates a new lazy connection manager @@ -73,6 +81,7 @@ func NewManager(config Config, engineCtx context.Context, peerStore *peerstore.S activityManager: activity.NewManager(wgIface), peerToHAGroups: make(map[string][]route.HAUniqueID), haGroupToPeers: make(map[route.HAUniqueID][]string), + reconcileAllowedIPs: config.ReconcileAllowedIPs, } if wgIface.IsUserspaceBind() { @@ -201,7 +210,7 @@ func (m *Manager) AddPeer(peerCfg lazyconn.PeerConfig) (bool, error) { return false, nil } - if err := m.activityManager.MonitorPeerActivity(peerCfg); err != nil { + if err := m.armActivityListener(peerCfg); err != nil { return false, err } @@ -288,7 +297,7 @@ func (m *Manager) DeactivatePeer(peerID peerid.ConnID) { m.inactivityManager.RemovePeer(mp.peerCfg.PublicKey) - if err := m.activityManager.MonitorPeerActivity(*mp.peerCfg); err != nil { + if err := m.armActivityListener(*mp.peerCfg); err != nil { mp.peerCfg.Log.Errorf("failed to create activity monitor: %v", err) return } @@ -465,6 +474,31 @@ func (m *Manager) close() { } // shouldDeferIdleForHA checks if peer should stay connected due to HA group requirements +// armRoutedAllowedIPs re-applies the peer's routed allowed IPs onto its freshly armed wake +// endpoint. The activity listener creates the wake peer with the overlay /32 only, so without +// this the routed prefixes would be missing and traffic to a routed subnet could not wake the +// idle routing peer. It is a no-op when no reconciler is configured. +// armActivityListener (re)arms the peer's wake endpoint via the activity manager and then +// re-applies its routed allowed IPs, so traffic to a routed subnet can wake an idle routing +// peer. The routed prefixes must be re-applied after the wake endpoint exists because the +// listener creates it with the overlay /32 only. +func (m *Manager) armActivityListener(peerCfg lazyconn.PeerConfig) error { + if err := m.activityManager.MonitorPeerActivity(peerCfg); err != nil { + return err + } + m.armRoutedAllowedIPs(&peerCfg) + return nil +} + +func (m *Manager) armRoutedAllowedIPs(peerCfg *lazyconn.PeerConfig) { + if m.reconcileAllowedIPs == nil { + return + } + if err := m.reconcileAllowedIPs(peerCfg.PublicKey); err != nil { + peerCfg.Log.Errorf("failed to reconcile routed allowed IPs on wake endpoint: %v", err) + } +} + func (m *Manager) shouldDeferIdleForHA(inactivePeers map[string]struct{}, peerID string) bool { m.routesMu.RLock() defer m.routesMu.RUnlock() @@ -577,7 +611,7 @@ func (m *Manager) onPeerInactivityTimedOut(peerIDs map[string]struct{}) { mp.peerCfg.Log.Infof("start activity monitor") - if err := m.activityManager.MonitorPeerActivity(*mp.peerCfg); err != nil { + if err := m.armActivityListener(*mp.peerCfg); err != nil { mp.peerCfg.Log.Errorf("failed to create activity monitor: %v", err) continue } diff --git a/client/internal/localmetrics/localmetrics.go b/client/internal/localmetrics/localmetrics.go new file mode 100644 index 000000000..f829fa132 --- /dev/null +++ b/client/internal/localmetrics/localmetrics.go @@ -0,0 +1,274 @@ +// Package localmetrics exposes client connection state as a local +// Prometheus /metrics endpoint. +package localmetrics + +import ( + "context" + "errors" + "net" + "net/http" + "net/netip" + "sync" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + dto "github.com/prometheus/client_model/go" + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/peer" +) + +// DefaultListenAddress is used when local metrics are enabled without an explicit address. +const DefaultListenAddress = "127.0.0.1:9191" + +const ( + shutdownTimeout = 3 * time.Second + readHeaderTimeout = 5 * time.Second + readTimeout = 10 * time.Second + writeTimeout = 30 * time.Second + idleTimeout = time.Minute +) + +// statusSource provides the connection state snapshots the collector reads on scrape. +type statusSource interface { + GetPeerStates() []peer.State + GetManagementState() peer.ManagementState + GetSignalState() peer.SignalState +} + +// GathererProvider returns the current client metrics gatherer, or nil when +// no engine is running. It is called on every scrape. +type GathererProvider func() prometheus.Gatherer + +// Manager runs the local /metrics HTTP endpoint according to the active +// client configuration. Reconcile is safe to call on every config change. +type Manager struct { + status statusSource + clientMetrics GathererProvider + + mu sync.Mutex + srv *http.Server + addr string +} + +// NewManager creates a manager that serves metrics from status and +// clientMetrics and shuts down when ctx is canceled. +func NewManager(ctx context.Context, status statusSource, clientMetrics GathererProvider) *Manager { + m := &Manager{status: status, clientMetrics: clientMetrics} + go func() { + <-ctx.Done() + m.Stop() + }() + return m +} + +// Reconcile starts, stops, or restarts the metrics endpoint to match the +// desired state. An empty addr falls back to DefaultListenAddress. +func (m *Manager) Reconcile(enabled bool, addr string) { + if addr == "" { + addr = DefaultListenAddress + } + warnIfNotLoopback(addr) + + m.mu.Lock() + defer m.mu.Unlock() + + if !enabled { + m.stop() + return + } + if m.srv != nil && m.addr == addr { + return + } + m.stop() + + registry := prometheus.NewRegistry() + registry.MustRegister(newCollector(m.status)) + + gatherers := prometheus.Gatherers{registry, prometheus.GathererFunc(func() ([]*dto.MetricFamily, error) { + if m.clientMetrics == nil { + return nil, nil + } + g := m.clientMetrics() + if g == nil { + return nil, nil + } + return g.Gather() + })} + + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.HandlerFor(gatherers, promhttp.HandlerOpts{})) + + srv := &http.Server{ + Addr: addr, + Handler: mux, + ReadHeaderTimeout: readHeaderTimeout, + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, + IdleTimeout: idleTimeout, + } + m.srv = srv + m.addr = addr + + log.Infof("serving local metrics on http://%s/metrics", addr) + go func() { + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Errorf("failed to serve local metrics on %s: %v", addr, err) + m.clear(srv) + } + }() +} + +// clear drops the reference to srv so a later Reconcile with the same +// address restarts it. A newer server may already have replaced it, in +// which case the reference must stay. +func (m *Manager) clear(srv *http.Server) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.srv != srv { + return + } + m.srv = nil + m.addr = "" +} + +// Stop shuts down the metrics endpoint if it is running. +func (m *Manager) Stop() { + m.mu.Lock() + defer m.mu.Unlock() + m.stop() +} + +// stop shuts down the running server. Callers must hold m.mu. +func (m *Manager) stop() { + if m.srv == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + if err := m.srv.Shutdown(ctx); err != nil { + log.Debugf("failed to shut down local metrics server: %v", err) + } + m.srv = nil + m.addr = "" +} + +// collector converts status recorder snapshots into Prometheus metrics at scrape time. +type collector struct { + status statusSource + + managementConnected *prometheus.Desc + signalConnected *prometheus.Desc + peersTotal *prometheus.Desc + peersConnected *prometheus.Desc + peerLatency *prometheus.Desc +} + +func newCollector(status statusSource) *collector { + return &collector{ + status: status, + managementConnected: prometheus.NewDesc( + "netbird_management_connected", + "Whether the client is connected to the management service (1 connected, 0 disconnected).", + nil, nil, + ), + signalConnected: prometheus.NewDesc( + "netbird_signal_connected", + "Whether the client is connected to the signal service (1 connected, 0 disconnected).", + nil, nil, + ), + peersTotal: prometheus.NewDesc( + "netbird_peers", + "Number of peers known to this client.", + nil, nil, + ), + peersConnected: prometheus.NewDesc( + "netbird_peers_connected", + "Number of connected peers by connection type.", + []string{"connection_type"}, nil, + ), + peerLatency: prometheus.NewDesc( + "netbird_peer_latency_seconds", + "Round-trip latency per directly connected peer; relayed connections have no latency measurement.", + []string{"peer"}, nil, + ), + } +} + +// Describe implements prometheus.Collector. +func (c *collector) Describe(ch chan<- *prometheus.Desc) { + ch <- c.managementConnected + ch <- c.signalConnected + ch <- c.peersTotal + ch <- c.peersConnected + ch <- c.peerLatency +} + +// Collect implements prometheus.Collector. +func (c *collector) Collect(ch chan<- prometheus.Metric) { + ch <- prometheus.MustNewConstMetric(c.managementConnected, prometheus.GaugeValue, boolToFloat(c.status.GetManagementState().Connected)) + ch <- prometheus.MustNewConstMetric(c.signalConnected, prometheus.GaugeValue, boolToFloat(c.status.GetSignalState().Connected)) + + peers := c.status.GetPeerStates() + ch <- prometheus.MustNewConstMetric(c.peersTotal, prometheus.GaugeValue, float64(len(peers))) + + var p2p, relayed float64 + for _, p := range peers { + if p.ConnStatus != peer.StatusConnected { + continue + } + if p.Relayed { + relayed++ + continue + } + p2p++ + + if latency := p.Latency.Seconds(); latency > 0 { + ch <- prometheus.MustNewConstMetric(c.peerLatency, prometheus.GaugeValue, latency, p.FQDN) + } + } + ch <- prometheus.MustNewConstMetric(c.peersConnected, prometheus.GaugeValue, p2p, "p2p") + ch <- prometheus.MustNewConstMetric(c.peersConnected, prometheus.GaugeValue, relayed, "relay") +} + +func boolToFloat(b bool) float64 { + if b { + return 1 + } + return 0 +} + +// IsLoopback reports whether addr binds the endpoint to the local host only. +// An empty address means DefaultListenAddress. It fails closed: an address +// that cannot be confirmed loopback, including an unparseable one, is not. +func IsLoopback(addr string) bool { + if addr == "" { + addr = DefaultListenAddress + } + + host, _, err := net.SplitHostPort(addr) + if err != nil { + return false + } + if host == "localhost" { + return true + } + + ip, err := netip.ParseAddr(host) + if err != nil { + return false + } + return ip.Unmap().IsLoopback() +} + +// warnIfNotLoopback logs a warning when the listen address cannot be +// confirmed to be local-only, since the endpoint exposes peer and +// connectivity details without authentication. +func warnIfNotLoopback(addr string) { + if IsLoopback(addr) { + return + } + log.Warnf("local metrics endpoint listens on non-loopback address %s and is reachable from the network without authentication", addr) +} diff --git a/client/internal/localmetrics/localmetrics_test.go b/client/internal/localmetrics/localmetrics_test.go new file mode 100644 index 000000000..727137077 --- /dev/null +++ b/client/internal/localmetrics/localmetrics_test.go @@ -0,0 +1,151 @@ +package localmetrics + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "strings" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/peer" +) + +type stubStatus struct { + peers []peer.State + management peer.ManagementState + signal peer.SignalState +} + +func (s *stubStatus) GetPeerStates() []peer.State { return s.peers } +func (s *stubStatus) GetManagementState() peer.ManagementState { return s.management } +func (s *stubStatus) GetSignalState() peer.SignalState { return s.signal } + +func testStatus() *stubStatus { + return &stubStatus{ + management: peer.ManagementState{Connected: true}, + signal: peer.SignalState{Connected: true}, + peers: []peer.State{ + {FQDN: "peer-a.netbird.cloud", IP: "100.90.0.1", ConnStatus: peer.StatusConnected, Relayed: false, Latency: 12 * time.Millisecond}, + {FQDN: "peer-b.netbird.cloud", IP: "100.90.0.2", ConnStatus: peer.StatusConnected, Relayed: false, Latency: 36 * time.Millisecond}, + {FQDN: "peer-c.netbird.cloud", IP: "100.90.0.3", ConnStatus: peer.StatusConnected, Relayed: true}, + {FQDN: "peer-d.netbird.cloud", IP: "100.90.0.4", ConnStatus: peer.StatusIdle}, + }, + } +} + +func TestCollector(t *testing.T) { + c := newCollector(testStatus()) + + expected := ` +# HELP netbird_management_connected Whether the client is connected to the management service (1 connected, 0 disconnected). +# TYPE netbird_management_connected gauge +netbird_management_connected 1 +# HELP netbird_peer_latency_seconds Round-trip latency per directly connected peer; relayed connections have no latency measurement. +# TYPE netbird_peer_latency_seconds gauge +netbird_peer_latency_seconds{peer="peer-a.netbird.cloud"} 0.012 +netbird_peer_latency_seconds{peer="peer-b.netbird.cloud"} 0.036 +# HELP netbird_peers Number of peers known to this client. +# TYPE netbird_peers gauge +netbird_peers 4 +# HELP netbird_peers_connected Number of connected peers by connection type. +# TYPE netbird_peers_connected gauge +netbird_peers_connected{connection_type="p2p"} 2 +netbird_peers_connected{connection_type="relay"} 1 +# HELP netbird_signal_connected Whether the client is connected to the signal service (1 connected, 0 disconnected). +# TYPE netbird_signal_connected gauge +netbird_signal_connected 1 +` + require.NoError(t, testutil.CollectAndCompare(c, strings.NewReader(expected))) +} + +func TestServe(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err, "must find a free port") + addr := ln.Addr().String() + require.NoError(t, ln.Close()) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + m := NewManager(ctx, testStatus(), nil) + m.Reconcile(true, addr) + + var body string + require.Eventually(t, func() bool { + resp, err := http.Get(fmt.Sprintf("http://%s/metrics", addr)) + if err != nil { + return false + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil || resp.StatusCode != http.StatusOK { + return false + } + body = string(data) + return true + }, 2*time.Second, 50*time.Millisecond, "metrics endpoint should come up") + + assert.Contains(t, body, "netbird_peers 4") + assert.Contains(t, body, `netbird_peers_connected{connection_type="relay"} 1`) + assert.Contains(t, body, `netbird_peer_latency_seconds{peer="peer-a.netbird.cloud"} 0.012`) +} + +// A server that never came up must not be remembered, otherwise reconciling the +// same address again is a no-op and the endpoint never recovers. +func TestReconcileForgetsAFailedServer(t *testing.T) { + blocker, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err, "must find a free port") + t.Cleanup(func() { _ = blocker.Close() }) + addr := blocker.Addr().String() + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + m := NewManager(ctx, testStatus(), nil) + m.Reconcile(true, addr) + + require.Eventually(t, func() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.srv == nil && m.addr == "" + }, 2*time.Second, 20*time.Millisecond, "the failed server should be dropped") + + require.NoError(t, blocker.Close()) + m.Reconcile(true, addr) + + require.Eventually(t, func() bool { + resp, err := http.Get(fmt.Sprintf("http://%s/metrics", addr)) + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK + }, 2*time.Second, 50*time.Millisecond, "reconciling the same address should retry the bind") +} + +func TestIsLoopback(t *testing.T) { + tests := map[string]bool{ + "": true, + "127.0.0.1:9191": true, + "127.9.9.9:9191": true, + "[::1]:9191": true, + "[::ffff:127.0.0.1]:9191": true, + "localhost:9191": true, + "0.0.0.0:9191": false, + "[::]:9191": false, + "192.168.1.10:9191": false, + "not-an-address": false, + "example.com:9191": false, + } + + for addr, want := range tests { + t.Run(addr, func(t *testing.T) { + assert.Equal(t, want, IsLoopback(addr), "loopback verdict for %q", addr) + }) + } +} diff --git a/client/internal/message_convert.go b/client/internal/message_convert.go index 97da32c06..60f19e228 100644 --- a/client/internal/message_convert.go +++ b/client/internal/message_convert.go @@ -10,21 +10,6 @@ import ( mgmProto "github.com/netbirdio/netbird/shared/management/proto" ) -func convertToFirewallProtocol(protocol mgmProto.RuleProtocol) (firewallManager.Protocol, error) { - switch protocol { - case mgmProto.RuleProtocol_TCP: - return firewallManager.ProtocolTCP, nil - case mgmProto.RuleProtocol_UDP: - return firewallManager.ProtocolUDP, nil - case mgmProto.RuleProtocol_ICMP: - return firewallManager.ProtocolICMP, nil - case mgmProto.RuleProtocol_ALL: - return firewallManager.ProtocolALL, nil - default: - return "", fmt.Errorf("invalid protocol type: %s", protocol.String()) - } -} - func convertPortInfo(portInfo *mgmProto.PortInfo) (*firewallManager.Port, error) { if portInfo == nil { return nil, errors.New("portInfo cannot be nil") diff --git a/client/internal/metrics/connection_type.go b/client/internal/metrics/connection_type.go index a3406a6b8..d393e5112 100644 --- a/client/internal/metrics/connection_type.go +++ b/client/internal/metrics/connection_type.go @@ -4,11 +4,17 @@ package metrics type ConnectionType string const ( - // ConnectionTypeICE represents a direct peer-to-peer connection using ICE - ConnectionTypeICE ConnectionType = "ice" + // ConnectionTypeICEP2P represents a direct peer-to-peer connection using ICE + ConnectionTypeICEP2P ConnectionType = "ice_p2p" + + // ConnectionTypeICETurn represents an ICE connection through a TURN server + ConnectionTypeICETurn ConnectionType = "ice_turn" // ConnectionTypeRelay represents a relayed connection ConnectionTypeRelay ConnectionType = "relay" + + // ConnectionTypeUnknown represents a connection with no active transport. It is not pushed. + ConnectionTypeUnknown ConnectionType = "unknown" ) // String returns the string representation of the connection type diff --git a/client/internal/metrics/influxdb.go b/client/internal/metrics/influxdb.go index 4ba14bf44..717544f6a 100644 --- a/client/internal/metrics/influxdb.go +++ b/client/internal/metrics/influxdb.go @@ -45,30 +45,13 @@ func (m *influxDBMetrics) RecordConnectionStages( isReconnection bool, timestamps ConnectionStageTimestamps, ) { - var signalingReceivedToConnection, connectionToWgHandshake, totalDuration float64 - - if !timestamps.SignalingReceived.IsZero() && !timestamps.ConnectionReady.IsZero() { - signalingReceivedToConnection = timestamps.ConnectionReady.Sub(timestamps.SignalingReceived).Seconds() - } - - if !timestamps.ConnectionReady.IsZero() && !timestamps.WgHandshakeSuccess.IsZero() { - connectionToWgHandshake = timestamps.WgHandshakeSuccess.Sub(timestamps.ConnectionReady).Seconds() - } - - if !timestamps.SignalingReceived.IsZero() && !timestamps.WgHandshakeSuccess.IsZero() { - totalDuration = timestamps.WgHandshakeSuccess.Sub(timestamps.SignalingReceived).Seconds() - } - - attemptType := "initial" - if isReconnection { - attemptType = "reconnection" - } + signalingReceivedToConnection, connectionToWgHandshake, totalDuration := timestamps.Durations() connTypeStr := connectionType.String() tags := fmt.Sprintf("deployment_type=%s,connection_type=%s,attempt_type=%s,version=%s,os=%s,arch=%s,peer_id=%s,connection_pair_id=%s", agentInfo.DeploymentType.String(), connTypeStr, - attemptType, + attemptType(isReconnection), agentInfo.Version, agentInfo.OS, agentInfo.Arch, @@ -94,7 +77,7 @@ func (m *influxDBMetrics) RecordConnectionStages( m.trimLocked() log.Tracef("peer connection metrics [%s, %s, %s]: signalingReceived→connection: %.3fs, connection→wg_handshake: %.3fs, total: %.3fs", - agentInfo.DeploymentType.String(), connTypeStr, attemptType, signalingReceivedToConnection, connectionToWgHandshake, totalDuration) + agentInfo.DeploymentType.String(), connTypeStr, attemptType(isReconnection), signalingReceivedToConnection, connectionToWgHandshake, totalDuration) } func (m *influxDBMetrics) RecordSyncDuration(_ context.Context, agentInfo AgentInfo, duration time.Duration) { diff --git a/client/internal/metrics/influxdb_test.go b/client/internal/metrics/influxdb_test.go index b964e31a3..6a226fe2f 100644 --- a/client/internal/metrics/influxdb_test.go +++ b/client/internal/metrics/influxdb_test.go @@ -28,7 +28,7 @@ func TestInfluxDBMetrics_RecordAndExport(t *testing.T) { WgHandshakeSuccess: time.Now().Add(-1 * time.Second), } - m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICE, false, ts) + m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICEP2P, false, ts) var buf bytes.Buffer err := m.Export(&buf) @@ -60,7 +60,7 @@ func TestInfluxDBMetrics_ExportDeterministicFieldOrder(t *testing.T) { // Record multiple times and verify consistent field order for i := 0; i < 10; i++ { - m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICE, false, ts) + m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICEP2P, false, ts) } var buf bytes.Buffer diff --git a/client/internal/metrics/infra/README.md b/client/internal/metrics/infra/README.md index 7941a30cf..7c23e42bd 100644 --- a/client/internal/metrics/infra/README.md +++ b/client/internal/metrics/infra/README.md @@ -32,13 +32,24 @@ Clients do not talk to InfluxDB directly. An ingest server sits between clients ```text Client ──POST──▶ Ingest Server (:8087) ──▶ InfluxDB (internal) │ + ├─ Checks the X-Peer-ID header format ├─ Validates line protocol ├─ Allowlists measurements, fields, and tags ├─ Rejects out-of-bound values └─ Serves remote config at /config ``` -- **No secret/token-based client auth** — the ingest server holds the InfluxDB token server-side. Clients must send a hashed peer ID via `X-Peer-ID` header. +- **Intentionally unauthenticated** — the endpoint receives obfuscated telemetry from + the peers of both cloud and self-hosted deployments. For a self-hosted peer there is + no shared trust anchor with this server, so there is nothing to authenticate against. +- **`X-Peer-ID` is a correlation tag, not a credential** — it carries the obfuscated + peer identifier so samples from one peer can be grouped. The server only checks that + the header is well-formed (16 hex chars); a malformed value is rejected with + `400 Bad Request`, not `401`. Any well-formed value is accepted by design, and the + header must not be relied on for access control. The header itself is not forwarded + to InfluxDB — the stored `peer_id` tag comes from the request body and is constrained + only by the tag allowlist and the maximum tag value length. +- **The InfluxDB token stays server-side** — clients never hold a write credential. - **InfluxDB is not exposed** — only accessible within the docker network - Source: `ingest/main.go` @@ -56,14 +67,33 @@ Measurement: `netbird_peer_connection` Tags: - `deployment_type`: "cloud" | "selfhosted" | "unknown" -- `connection_type`: "ice" | "relay" +- `connection_type`: "ice_p2p" | "ice_turn" | "relay" (see below) - `attempt_type`: "initial" | "reconnection" - `version`: NetBird version string - `os`: Operating system (linux, darwin, windows, android, ios, etc.) - `arch`: CPU architecture (amd64, arm64, etc.) +- `peer_id`: obfuscated peer identifier (truncated SHA-256 of the WireGuard public key) +- `connection_pair_id`: deterministic identifier for the peer pair, identical on both sides **Note:** `SignalingReceived` is set when the first offer or answer arrives from the remote peer (in both initial and reconnection paths). It excludes the potentially unbounded wait for the remote peer to come online. +#### `connection_type` values + +Derived from the connection priority (`conntype.ConnPriority`) by `metricsConnType` in `client/internal/peer/conn.go`: + +| Value | Priority | Traffic is | +|-------|----------|------------| +| `ice_p2p` | `ICEP2P` | direct peer-to-peer | +| `ice_turn` | `ICETurn` | relayed, through a TURN server | +| `relay` | `Relay` | relayed, through a NetBird relay | +| `unknown` | `None` or unrecognised | no active transport — **the sample is not pushed** | + +**Direct traffic is `ice_p2p` only.** `ice_turn` is relayed despite being negotiated by ICE, matching `Conn.isRelayed`. + +`None` means no transport is active: not established yet, or reset after a relay drop or a peer-state reset. Such a sample cannot be attributed to a transport, so `recordConnectionMetrics` drops it instead of pushing it — `unknown` therefore never appears in the bucket. Connection counts are counts of connections whose transport was known at sampling time. + +**Samples recorded before 0.77 used a single `ice` value** which covered `ICEP2P`, `ICETurn` *and* `None`, so historical `ice` samples overstate direct connections by an unknown amount and must not be compared with `ice_p2p`. + ### Sync Duration Measurement: `netbird_sync` @@ -176,7 +206,7 @@ docker compose up -d ``` This starts: -- **Ingest server** on http://localhost:8087 — accepts client metrics (requires `X-Peer-ID` header, no secret/token auth) +- **Ingest server** on http://localhost:8087 — accepts client metrics (unauthenticated by design; expects a well-formed `X-Peer-ID` correlation tag) - **InfluxDB** — internal only, not exposed to host - **Grafana** on http://localhost:3001 diff --git a/client/internal/metrics/infra/ingest/main.go b/client/internal/metrics/infra/ingest/main.go index 91405b85f..a9fb25178 100644 --- a/client/internal/metrics/infra/ingest/main.go +++ b/client/internal/metrics/infra/ingest/main.go @@ -22,6 +22,11 @@ const ( maxDurationSeconds = 86400.0 // reject any duration field > 24 hours peerIDLength = 16 // truncated SHA-256: 8 bytes = 16 hex chars maxTagValueLength = 64 // reject tag values longer than this + readTimeout = 30 * time.Second // must fit reading a compressed body up to maxBodySize + writeTimeout = 60 * time.Second // must exceed the upstream client timeout below + idleTimeout = 120 * time.Second + readHeaderTimeout = 10 * time.Second + maxHeaderBytes = 1 << 20 // 1 MB ) type measurementSpec struct { @@ -124,8 +129,17 @@ func main() { fmt.Fprint(w, "ok") //nolint:errcheck }) + srv := &http.Server{ + Addr: listenAddr, + ReadTimeout: readTimeout, + ReadHeaderTimeout: readHeaderTimeout, + WriteTimeout: writeTimeout, + IdleTimeout: idleTimeout, + MaxHeaderBytes: maxHeaderBytes, + } + log.Printf("ingest server listening on %s, forwarding to %s", listenAddr, influxURL) - if err := http.ListenAndServe(listenAddr, nil); err != nil { //nolint:gosec + if err := srv.ListenAndServe(); err != nil { log.Fatal(err) } } @@ -137,8 +151,8 @@ func handleIngest(client *http.Client, influxURL, influxToken string) http.Handl return } - if err := validateAuth(r); err != nil { - http.Error(w, err.Error(), http.StatusUnauthorized) + if err := validatePeerIDFormat(r); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -187,8 +201,13 @@ func forwardToInflux(w http.ResponseWriter, r *http.Request, client *http.Client io.Copy(w, resp.Body) //nolint:errcheck } -// validateAuth checks that the X-Peer-ID header contains a valid hashed peer ID. -func validateAuth(r *http.Request) error { +// validatePeerIDFormat checks the shape of the X-Peer-ID header. The header is a +// correlation tag, not a credential: this endpoint is intentionally +// unauthenticated so that peers of self-hosted deployments, for which no shared +// trust anchor exists, can report obfuscated telemetry. The header is not forwarded +// to InfluxDB, so this check does not bound the stored peer_id tag; it only rejects +// a malformed header as a bad request rather than an auth failure. +func validatePeerIDFormat(r *http.Request) error { peerID := r.Header.Get("X-Peer-ID") if peerID == "" { return fmt.Errorf("missing X-Peer-ID header") diff --git a/client/internal/metrics/infra/ingest/main_test.go b/client/internal/metrics/infra/ingest/main_test.go index 96287813e..526f127cb 100644 --- a/client/internal/metrics/infra/ingest/main_test.go +++ b/client/internal/metrics/infra/ingest/main_test.go @@ -94,7 +94,7 @@ func TestValidateLineProtocol_RejectsOnBadLine(t *testing.T) { require.Error(t, err) } -func TestValidateAuth(t *testing.T) { +func TestValidatePeerIDFormat(t *testing.T) { tests := []struct { name string peerID string @@ -113,7 +113,7 @@ func TestValidateAuth(t *testing.T) { if tt.peerID != "" { r.Header.Set("X-Peer-ID", tt.peerID) } - err := validateAuth(r) + err := validatePeerIDFormat(r) if tt.wantErr { require.Error(t, err) } else { diff --git a/client/internal/metrics/metrics.go b/client/internal/metrics/metrics.go index cfe477107..5edf1d9c7 100644 --- a/client/internal/metrics/metrics.go +++ b/client/internal/metrics/metrics.go @@ -89,6 +89,21 @@ type ConnectionStageTimestamps struct { WgHandshakeSuccess time.Time } +// Durations returns the stage durations in seconds. A duration is zero when +// either of its timestamps is missing. +func (c ConnectionStageTimestamps) Durations() (signalingToConnection, connectionToWgHandshake, total float64) { + if !c.SignalingReceived.IsZero() && !c.ConnectionReady.IsZero() { + signalingToConnection = c.ConnectionReady.Sub(c.SignalingReceived).Seconds() + } + if !c.ConnectionReady.IsZero() && !c.WgHandshakeSuccess.IsZero() { + connectionToWgHandshake = c.WgHandshakeSuccess.Sub(c.ConnectionReady).Seconds() + } + if !c.SignalingReceived.IsZero() && !c.WgHandshakeSuccess.IsZero() { + total = c.WgHandshakeSuccess.Sub(c.SignalingReceived).Seconds() + } + return signalingToConnection, connectionToWgHandshake, total +} + // String returns a human-readable representation of the connection stage timestamps func (c ConnectionStageTimestamps) String() string { return fmt.Sprintf("ConnectionStageTimestamps{SignalingReceived=%v, ConnectionReady=%v, WgHandshakeSuccess=%v}", @@ -279,3 +294,11 @@ func (c *ClientMetrics) stopPushLocked() { c.wg.Wait() c.push.Store(nil) } + +// attemptType returns the metric label for an initial vs reconnection attempt. +func attemptType(isReconnection bool) string { + if isReconnection { + return "reconnection" + } + return "initial" +} diff --git a/client/internal/metrics/metrics_default.go b/client/internal/metrics/metrics_default.go index 927ab51d1..3798adab6 100644 --- a/client/internal/metrics/metrics_default.go +++ b/client/internal/metrics/metrics_default.go @@ -2,10 +2,24 @@ package metrics +import "github.com/prometheus/client_golang/prometheus" + // NewClientMetrics creates a new ClientMetrics instance func NewClientMetrics(agentInfo AgentInfo) *ClientMetrics { return &ClientMetrics{ - impl: newInfluxDBMetrics(), + impl: newPrometheusMetrics(newInfluxDBMetrics()), agentInfo: agentInfo, } } + +// PrometheusGatherer returns the registry with the mirrored Prometheus +// metrics, or nil when unavailable. +func (c *ClientMetrics) PrometheusGatherer() prometheus.Gatherer { + if c == nil { + return nil + } + if pm, ok := c.impl.(*prometheusMetrics); ok { + return pm.Gatherer() + } + return nil +} diff --git a/client/internal/metrics/prometheus.go b/client/internal/metrics/prometheus.go new file mode 100644 index 000000000..7f5020ea9 --- /dev/null +++ b/client/internal/metrics/prometheus.go @@ -0,0 +1,119 @@ +//go:build !js + +package metrics + +import ( + "context" + "io" + "strconv" + "time" + + "github.com/prometheus/client_golang/prometheus" +) + +// prometheusMetrics mirrors recorded client metrics into a Prometheus +// registry for the local /metrics endpoint, then delegates to the wrapped +// implementation. Export and Reset pass through untouched: Prometheus +// metrics are cumulative and pull-based. +type prometheusMetrics struct { + next metricsImplementation + registry *prometheus.Registry + + connectionStages *prometheus.HistogramVec + syncDuration prometheus.Histogram + syncPhaseDuration *prometheus.HistogramVec + loginDuration *prometheus.HistogramVec +} + +func newPrometheusMetrics(next metricsImplementation) *prometheusMetrics { + connectionBuckets := []float64{.05, .1, .25, .5, 1, 2.5, 5, 10, 30, 60} + + m := &prometheusMetrics{ + next: next, + registry: prometheus.NewRegistry(), + connectionStages: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "netbird_peer_connection_stage_duration_seconds", + Help: "Duration of peer connection establishment stages.", + Buckets: connectionBuckets, + }, []string{"stage", "connection_type", "attempt_type"}), + syncDuration: prometheus.NewHistogram(prometheus.HistogramOpts{ + Name: "netbird_sync_duration_seconds", + Help: "Duration of management sync message processing.", + Buckets: prometheus.DefBuckets, + }), + syncPhaseDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "netbird_sync_phase_duration_seconds", + Help: "Duration of individual sync processing phases.", + Buckets: prometheus.DefBuckets, + }, []string{"phase"}), + loginDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "netbird_login_duration_seconds", + Help: "Duration of logins to the management service.", + Buckets: prometheus.DefBuckets, + }, []string{"success"}), + } + + m.registry.MustRegister(m.connectionStages, m.syncDuration, m.syncPhaseDuration, m.loginDuration) + return m +} + +// Gatherer returns the registry holding the mirrored metrics. +func (m *prometheusMetrics) Gatherer() prometheus.Gatherer { + return m.registry +} + +// RecordConnectionStages implements metricsImplementation. +func (m *prometheusMetrics) RecordConnectionStages( + ctx context.Context, + agentInfo AgentInfo, + connectionPairID string, + connectionType ConnectionType, + isReconnection bool, + timestamps ConnectionStageTimestamps, +) { + attempt := attemptType(isReconnection) + connType := connectionType.String() + + signalingToConnection, connectionToWgHandshake, total := timestamps.Durations() + if signalingToConnection > 0 { + m.connectionStages.WithLabelValues("signaling_to_connection", connType, attempt).Observe(signalingToConnection) + } + if connectionToWgHandshake > 0 { + m.connectionStages.WithLabelValues("connection_to_wg_handshake", connType, attempt).Observe(connectionToWgHandshake) + } + if total > 0 { + m.connectionStages.WithLabelValues("total", connType, attempt).Observe(total) + } + + m.next.RecordConnectionStages(ctx, agentInfo, connectionPairID, connectionType, isReconnection, timestamps) +} + +// RecordSyncDuration implements metricsImplementation. +func (m *prometheusMetrics) RecordSyncDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration) { + m.syncDuration.Observe(duration.Seconds()) + m.next.RecordSyncDuration(ctx, agentInfo, duration) +} + +// RecordSyncPhase implements metricsImplementation. +func (m *prometheusMetrics) RecordSyncPhase(ctx context.Context, agentInfo AgentInfo, phase string, duration time.Duration) { + m.syncPhaseDuration.WithLabelValues(phase).Observe(duration.Seconds()) + m.next.RecordSyncPhase(ctx, agentInfo, phase, duration) +} + +// RecordLoginDuration implements metricsImplementation. +func (m *prometheusMetrics) RecordLoginDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration, success bool) { + m.loginDuration.WithLabelValues(strconv.FormatBool(success)).Observe(duration.Seconds()) + m.next.RecordLoginDuration(ctx, agentInfo, duration, success) +} + +// Export implements metricsImplementation by delegating to the wrapped +// implementation; Prometheus metrics are pulled via the registry instead. +func (m *prometheusMetrics) Export(w io.Writer) error { + return m.next.Export(w) +} + +// Reset implements metricsImplementation by delegating to the wrapped +// implementation; Prometheus metrics must not be cleared on push. +func (m *prometheusMetrics) Reset() { + m.next.Reset() +} diff --git a/client/internal/mobile_dependency.go b/client/internal/mobile_dependency.go index 310d61a25..0234432b1 100644 --- a/client/internal/mobile_dependency.go +++ b/client/internal/mobile_dependency.go @@ -11,12 +11,14 @@ import ( // MobileDependency collect all dependencies for mobile platform type MobileDependency struct { - // Android only - TunAdapter device.TunAdapter - IFaceDiscover stdnet.ExternalIFaceDiscover + // Android and iOS NetworkChangeListener listener.NetworkChangeListener - HostDNSAddresses []netip.AddrPort - DnsReadyListener dns.ReadyListener + + // Android only + TunAdapter device.TunAdapter + IFaceDiscover stdnet.ExternalIFaceDiscover + HostDNSAddresses []netip.AddrPort + DnsReadyListener dns.ReadyListener // iOS only DnsManager dns.IosDnsManager diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go index 09a4e8b02..d73144773 100644 --- a/client/internal/peer/conn.go +++ b/client/internal/peer/conn.go @@ -26,6 +26,7 @@ import ( "github.com/netbirdio/netbird/client/internal/portforward" "github.com/netbirdio/netbird/client/internal/rosenpass" "github.com/netbirdio/netbird/client/internal/stdnet" + "github.com/netbirdio/netbird/client/netevents" "github.com/netbirdio/netbird/route" relayClient "github.com/netbirdio/netbird/shared/relay/client" ) @@ -93,6 +94,10 @@ type ConnConfig struct { // ICEConfig ICE protocol configuration ICEConfig icemaker.Config + + // NetMgr gates the reconnection guard on OS-reported network + // availability; nil disables gating. + NetMgr *netevents.Manager } type Conn struct { @@ -130,9 +135,10 @@ type Conn struct { // used to store the remote Rosenpass key for Relayed connection in case of connection update from ice rosenpassRemoteKey []byte - wgProxyICE wgproxy.Proxy - wgProxyRelay wgproxy.Proxy - handshaker *Handshaker + wgProxyICE wgproxy.Proxy + wgProxyRelay wgproxy.Proxy + relayedConnRef *relayClient.Conn + handshaker *Handshaker guard *guard.Guard wg sync.WaitGroup @@ -254,7 +260,7 @@ func (conn *Conn) open(engineCtx context.Context, firstPacket []byte) error { conn.handshaker.AddICEListener(conn.workerICE.OnNewOffer) } - conn.guard = guard.NewGuard(conn.Log, conn.isConnectedOnAllWay, conn.config.Timeout, conn.srWatcher) + conn.guard = guard.NewGuard(conn.Log, conn.isConnectedOnAllWay, conn.config.Timeout, conn.srWatcher, conn.config.NetMgr) conn.wg.Add(1) go func() { @@ -307,6 +313,8 @@ func (conn *Conn) Close(signalToRemote bool) { if conn.wgWatcherCancel != nil { conn.wgWatcherCancel() + conn.wgWatcher = nil + conn.wgWatcherCancel = nil } conn.workerRelay.CloseConn() if conn.workerICE != nil { @@ -438,7 +446,7 @@ func (conn *Conn) onICEConnectionIsReady(priority conntype.ConnPriority, iceConn conn.dumpState.NewLocalProxy() wgProxy, err = conn.newProxy(iceConnInfo.RemoteConn) if err != nil { - conn.Log.Errorf("failed to add turn net.Conn to local proxy: %v", err) + conn.Log.Errorf("failed to add relayed net.Conn to local proxy: %v", err) return } ep = wgProxy.EndpointAddr() @@ -553,7 +561,7 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) { conn.mu.Lock() defer conn.mu.Unlock() - if conn.ctx.Err() != nil { + if conn.ctx.Err() != nil || rci.relayedConn.Context().Err() != nil { if err := rci.relayedConn.Close(); err != nil { conn.Log.Warnf("failed to close unnecessary relayed connection: %v", err) } @@ -568,7 +576,9 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) { conn.Log.Errorf("failed to add relayed net.Conn to local proxy: %v", err) return } - wgProxy.SetDisconnectListener(conn.onRelayDisconnected) + wgProxy.SetDisconnectListener(func() { + conn.onRelayDisconnected(rci.relayedConn) + }) conn.dumpState.NewLocalProxy() @@ -576,7 +586,7 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) { if conn.isICEActive() { conn.Log.Debugf("do not switch to relay because current priority is: %s", conn.currentConnPriority.String()) - conn.setRelayedProxy(wgProxy) + conn.setRelayedProxy(wgProxy, rci.relayedConn) conn.statusRelay.SetConnected() conn.updateRelayStatus(rci.relayedConn.RemoteAddr().String(), rci.rosenpassPubKey, time.Now()) return @@ -607,15 +617,26 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) { conn.rosenpassRemoteKey = rci.rosenpassPubKey conn.currentConnPriority = conntype.Relay conn.statusRelay.SetConnected() - conn.setRelayedProxy(wgProxy) + conn.setRelayedProxy(wgProxy, rci.relayedConn) conn.updateRelayStatus(rci.relayedConn.RemoteAddr().String(), rci.rosenpassPubKey, updateTime) conn.Log.Infof("start to communicate with peer via relay") conn.doOnConnected(rci.rosenpassPubKey, rci.rosenpassAddr, updateTime) } -func (conn *Conn) onRelayDisconnected() { +// onRelayDisconnected reports the teardown of a relayed connection. relayedConn +// names the connection the signal belongs to, so a signal that arrives after +// its connection was replaced is ignored instead of tearing down its successor. +// A nil relayedConn means the caller does not track generations and the current +// connection is always torn down. +func (conn *Conn) onRelayDisconnected(relayedConn *relayClient.Conn) { conn.mu.Lock() defer conn.mu.Unlock() + + if relayedConn != nil && conn.relayedConnRef != relayedConn { + conn.Log.Debugf("ignoring relay disconnect of a superseded connection") + return + } + conn.handleRelayDisconnectedLocked() } @@ -639,6 +660,7 @@ func (conn *Conn) handleRelayDisconnectedLocked() { _ = conn.wgProxyRelay.CloseConn() conn.wgProxyRelay = nil } + conn.relayedConnRef = nil changed := conn.statusRelay.Get() != worker.StatusDisconnected if changed { @@ -876,9 +898,8 @@ func (conn *Conn) newProxy(remoteConn net.Conn) (wgproxy.Proxy, error) { } wgProxy := conn.config.WgConfig.WgInterface.GetProxy() - if err := wgProxy.AddTurnConn(conn.ctx, udpAddr, remoteConn); err != nil { - conn.Log.Errorf("failed to add turn net.Conn to local proxy: %v", err) - return nil, err + if err := wgProxy.AddRelayedConn(conn.ctx, udpAddr, remoteConn); err != nil { + return nil, fmt.Errorf("add relayed conn to proxy: %w", err) } return wgProxy, nil } @@ -924,13 +945,14 @@ func (conn *Conn) logTraceConnState() { } } -func (conn *Conn) setRelayedProxy(proxy wgproxy.Proxy) { +func (conn *Conn) setRelayedProxy(proxy wgproxy.Proxy, relayedConn *relayClient.Conn) { if conn.wgProxyRelay != nil { if err := conn.wgProxyRelay.CloseConn(); err != nil { conn.Log.Warnf("failed to close deprecated wg proxy conn: %v", err) } } conn.wgProxyRelay = proxy + conn.relayedConnRef = relayedConn } // onWGHandshakeSuccess is called when the first WireGuard handshake is detected @@ -959,12 +981,9 @@ func (conn *Conn) recordConnectionMetrics() { priority := conn.currentConnPriority conn.mu.Unlock() - var connType metrics.ConnectionType - switch priority { - case conntype.Relay: - connType = metrics.ConnectionTypeRelay - default: - connType = metrics.ConnectionTypeICE + connType := metricsConnType(priority) + if connType == metrics.ConnectionTypeUnknown { + return } // Record metrics with timestamps - duration calculation happens in metrics package @@ -1065,3 +1084,16 @@ func boolToConnStatus(connected bool) guard.ConnStatus { } return guard.ConnStatusDisconnected } + +func metricsConnType(priority conntype.ConnPriority) metrics.ConnectionType { + switch priority { + case conntype.Relay: + return metrics.ConnectionTypeRelay + case conntype.ICETurn: + return metrics.ConnectionTypeICETurn + case conntype.ICEP2P: + return metrics.ConnectionTypeICEP2P + default: + return metrics.ConnectionTypeUnknown + } +} diff --git a/client/internal/peer/conn_test.go b/client/internal/peer/conn_test.go index 49979ea83..b709d5e40 100644 --- a/client/internal/peer/conn_test.go +++ b/client/internal/peer/conn_test.go @@ -11,6 +11,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/internal/metrics" + "github.com/netbirdio/netbird/client/internal/peer/conntype" "github.com/netbirdio/netbird/client/internal/peer/dispatcher" "github.com/netbirdio/netbird/client/internal/peer/guard" "github.com/netbirdio/netbird/client/internal/peer/ice" @@ -386,3 +388,33 @@ func TestConn_onWGDisconnected_NoEscalationWithoutRosenpass(t *testing.T) { } assert.Empty(t, disconnected, "escalation must be limited to rosenpass connections") } + +func TestMetricsConnType(t *testing.T) { + tests := []struct { + name string + priority conntype.ConnPriority + expected metrics.ConnectionType + }{ + {"relay", conntype.Relay, metrics.ConnectionTypeRelay}, + {"ice over turn is relayed, not p2p", conntype.ICETurn, metrics.ConnectionTypeICETurn}, + {"direct p2p", conntype.ICEP2P, metrics.ConnectionTypeICEP2P}, + {"unset priority is unknown, not p2p", conntype.None, metrics.ConnectionTypeUnknown}, + {"unrecognised priority is unknown", conntype.ConnPriority(99), metrics.ConnectionTypeUnknown}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, metricsConnType(tc.priority)) + }) + } +} + +func TestMetricsConnType_RelayedMatchesIsRelayed(t *testing.T) { + for _, priority := range []conntype.ConnPriority{conntype.None, conntype.Relay, conntype.ICETurn, conntype.ICEP2P} { + conn := &Conn{currentConnPriority: priority} + tag := metricsConnType(priority) + relayedTag := tag == metrics.ConnectionTypeRelay || tag == metrics.ConnectionTypeICETurn + assert.Equal(t, conn.isRelayed(), relayedTag, + "priority %s: isRelayed and the %q metric tag must agree", priority, tag) + } +} diff --git a/client/internal/peer/guard/guard.go b/client/internal/peer/guard/guard.go index 6c2e846a9..73bab2a89 100644 --- a/client/internal/peer/guard/guard.go +++ b/client/internal/peer/guard/guard.go @@ -22,6 +22,12 @@ const ( type connStatusFunc func() ConnStatus +// NetworkWatcher is the availability view the guard gates reconnects on. +type NetworkWatcher interface { + IsOnline() bool + Changed() <-chan struct{} +} + // Guard is responsible for the reconnection logic. // It will trigger to send an offer to the peer then has connection issues. // Watch these events: @@ -31,20 +37,26 @@ type connStatusFunc func() ConnStatus // - Relayed connection disconnected // - ICE candidate changes type Guard struct { - log *log.Entry - isConnectedOnAllWay connStatusFunc - timeout time.Duration - srWatcher *SRWatcher + log *log.Entry + isConnectedOnAllWay connStatusFunc + timeout time.Duration + srWatcher *SRWatcher + // netWatcher gates reconnect attempts on OS-reported network availability; + // nil disables gating. + netWatcher NetworkWatcher relayedConnDisconnected chan struct{} iCEConnDisconnected chan struct{} } -func NewGuard(log *log.Entry, isConnectedFn connStatusFunc, timeout time.Duration, srWatcher *SRWatcher) *Guard { +// NewGuard creates a reconnection guard for a peer connection. A nil netWatcher +// disables network availability gating. +func NewGuard(log *log.Entry, isConnectedFn connStatusFunc, timeout time.Duration, srWatcher *SRWatcher, netWatcher NetworkWatcher) *Guard { return &Guard{ log: log, isConnectedOnAllWay: isConnectedFn, timeout: timeout, srWatcher: srWatcher, + netWatcher: netWatcher, relayedConnDisconnected: make(chan struct{}, 1), iCEConnDisconnected: make(chan struct{}, 1), } @@ -96,9 +108,19 @@ func (g *Guard) reconnectLoopWithRetry(ctx context.Context, callback func()) { iceState := &iceRetryState{log: g.log} defer iceState.reset() + var netChanged <-chan struct{} + if g.netWatcher != nil { + netChanged = g.netWatcher.Changed() + } + for { select { case <-tickerChannel: + // skip attempts while the OS reports no usable network; the + // netChanged case below resumes the loop once it returns + if g.netWatcher != nil && !g.netWatcher.IsOnline() { + continue + } switch g.isConnectedOnAllWay() { case ConnStatusConnected: // all good, nothing to do @@ -135,6 +157,23 @@ func (g *Guard) reconnectLoopWithRetry(ctx context.Context, callback func()) { tickerChannel = ticker.C iceState.reset() + case <-netChanged: + // Re-arm for the next transition before acting on this one. + netChanged = g.netWatcher.Changed() + if !g.netWatcher.IsOnline() { + continue + } + // Ticks skipped while offline drove the backoff towards its + // maximum without ever attempting, and left the ICE budget + // frozen — possibly in hourly mode. Recover on our own so the + // peer does not depend on a signal or relay event that never + // comes when both stayed up across the outage. + g.log.Debugf("network is back, reset reconnection ticker") + ticker.Stop() + ticker = g.newReconnectTicker(ctx) + tickerChannel = ticker.C + iceState.reset() + case <-ctx.Done(): g.log.Debugf("context is done, stop reconnect loop") return diff --git a/client/internal/peer/guard/guard_leak_test.go b/client/internal/peer/guard/guard_leak_test.go index ded3e4aea..3d82ec591 100644 --- a/client/internal/peer/guard/guard_leak_test.go +++ b/client/internal/peer/guard/guard_leak_test.go @@ -15,7 +15,7 @@ import ( func newTestGuard(status connStatusFunc) *Guard { srw := NewSRWatcher(nil, nil, nil, ice.Config{}) - return NewGuard(log.WithField("test", "guard"), status, 50*time.Millisecond, srw) + return NewGuard(log.WithField("test", "guard"), status, 50*time.Millisecond, srw, nil) } // countBackoffTickerGoroutines returns how many goroutines are currently sitting diff --git a/client/internal/peer/guard/guard_netstate_test.go b/client/internal/peer/guard/guard_netstate_test.go new file mode 100644 index 000000000..44999cae1 --- /dev/null +++ b/client/internal/peer/guard/guard_netstate_test.go @@ -0,0 +1,107 @@ +package guard + +import ( + "context" + "sync/atomic" + "testing" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/peer/ice" + "github.com/netbirdio/netbird/client/netevents/netstate" +) + +// newTestGuardWithNetState builds a guard with a realistic MaxInterval: the +// backoff must be able to grow well past the outage, as it does in production +// where the timeout is seconds to minutes. +func newTestGuardWithNetState(status connStatusFunc, netState *netstate.State) *Guard { + srw := NewSRWatcher(nil, nil, nil, ice.Config{}) + return NewGuard(log.WithField("test", "guard"), status, 30*time.Second, srw, netState) +} + +// TestGuard_RecoversAfterOfflineToOnline covers a peer that stays disconnected +// across a network outage while neither signal nor relay reports an event — +// both stayed up, as on a short airplane mode toggle over Wi-Fi. +// +// Every tick taken while offline is skipped, but it still advances the +// exponential backoff, so by the time the network returns the next tick can be +// tens of seconds away. Without an explicit reaction to the transition the +// peer waits out that interval for a recovery that could start immediately. +func TestGuard_RecoversAfterOfflineToOnline(t *testing.T) { + netState := netstate.New() + + var attempts atomic.Int32 + g := newTestGuardWithNetState(func() ConnStatus { return ConnStatusDisconnected }, netState) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + // Start from the reconnect ticker (800ms initial interval), the state a + // peer is in after it loses its connection. + go g.Start(ctx, func() { attempts.Add(1) }) + g.SetRelayedConnDisconnected() + + // Let the backoff climb: 0.8s, 1.6s, 3.2s, 6.4s ... every tick is skipped + // while offline, but each one doubles the wait for the next. + netState.Set(false) + time.Sleep(8 * time.Second) + + offlineAttempts := attempts.Load() + if offlineAttempts != 0 { + t.Fatalf("callback ran %d times while offline, want 0", offlineAttempts) + } + + netState.Set(true) + + // The next organic tick is now several seconds out, so anything within + // this window can only come from reacting to the transition itself. + pollCtx, stopPolling := context.WithTimeout(ctx, 2*time.Second) + defer stopPolling() + + select { + case <-pollCtx.Done(): + t.Fatal("peer was not retried within 2s of the network coming back, " + + "with neither a signal nor a relay event to fall back on") + case <-pollUntil(pollCtx, func() bool { return attempts.Load() > 0 }): + } +} + +// TestGuard_OfflineTransitionDoesNotRetry checks the other direction: going +// offline must not itself trigger an attempt. +func TestGuard_OfflineTransitionDoesNotRetry(t *testing.T) { + netState := netstate.New() + + var attempts atomic.Int32 + g := newTestGuardWithNetState(func() ConnStatus { return ConnStatusDisconnected }, netState) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go g.Start(ctx, func() { attempts.Add(1) }) + + netState.Set(false) + time.Sleep(5 * time.Second) + + if got := attempts.Load(); got != 0 { + t.Fatalf("callback ran %d times after going offline, want 0", got) + } +} + +// pollUntil closes the returned channel once cond holds. It gives up when ctx +// is done, so the polling goroutine never outlives the test that started it. +func pollUntil(ctx context.Context, cond func() bool) <-chan struct{} { + done := make(chan struct{}) + go func() { + for { + if cond() { + close(done) + return + } + select { + case <-ctx.Done(): + return + case <-time.After(10 * time.Millisecond): + } + } + }() + return done +} diff --git a/client/internal/peer/handshaker.go b/client/internal/peer/handshaker.go index 56e82e6e3..654e32158 100644 --- a/client/internal/peer/handshaker.go +++ b/client/internal/peer/handshaker.go @@ -81,14 +81,19 @@ type Handshaker struct { func NewHandshaker(log *log.Entry, config ConnConfig, signaler *Signaler, ice *WorkerICE, relay *WorkerRelay, metricsStages *MetricsStages) *Handshaker { h := &Handshaker{ - log: log, - config: config, - signaler: signaler, - ice: ice, - relay: relay, - metricsStages: metricsStages, - remoteOffersCh: make(chan OfferAnswer), - remoteAnswerCh: make(chan OfferAnswer), + log: log, + config: config, + signaler: signaler, + ice: ice, + relay: relay, + metricsStages: metricsStages, + // Buffered by one so an offer or answer that arrives between Open launching + // the Listen goroutine and it reaching its receive is held rather than + // dropped. A peer activated by an incoming signal receives the remote's + // message in that window; an unbuffered channel skips it as "receiver not + // ready", and the connection cannot proceed until the remote re-sends. + remoteOffersCh: make(chan OfferAnswer, 1), + remoteAnswerCh: make(chan OfferAnswer, 1), } // assume remote supports ICE until we learn otherwise from received offers h.remoteICESupported.Store(ice != nil) @@ -111,7 +116,7 @@ func (h *Handshaker) Listen(ctx context.Context) { for { select { case remoteOfferAnswer := <-h.remoteOffersCh: - h.log.Infof("received offer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials()) + h.log.Infof("received offer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t, relay server: %s, relay IP: %s", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials(), remoteOfferAnswer.RelaySrvAddress, remoteOfferAnswer.RelaySrvIP) // Record signaling received for reconnection attempts if h.metricsStages != nil { @@ -133,7 +138,7 @@ func (h *Handshaker) Listen(ctx context.Context) { continue } case remoteOfferAnswer := <-h.remoteAnswerCh: - h.log.Infof("received answer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials()) + h.log.Infof("received answer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t, relay server: %s, relay IP: %s", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials(), remoteOfferAnswer.RelaySrvAddress, remoteOfferAnswer.RelaySrvIP) // Record signaling received for reconnection attempts if h.metricsStages != nil { @@ -162,29 +167,38 @@ func (h *Handshaker) SendOffer() error { return h.sendOffer() } -// OnRemoteOffer handles an offer from the remote peer and returns true if the message was accepted, false otherwise -// doesn't block, discards the message if connection wasn't ready +// OnRemoteOffer hands an offer to Listen without blocking, keeping only the most +// recent one if several arrive before Listen reads them. func (h *Handshaker) OnRemoteOffer(offer OfferAnswer) { - select { - case h.remoteOffersCh <- offer: - return - default: - h.log.Warnf("skipping remote offer message because receiver not ready") - // connection might not be ready yet to receive so we ignore the message - return - } + enqueueLatest(h.remoteOffersCh, offer) } -// OnRemoteAnswer handles an offer from the remote peer and returns true if the message was accepted, false otherwise -// doesn't block, discards the message if connection wasn't ready +// OnRemoteAnswer hands an answer to Listen without blocking, keeping only the most +// recent one if several arrive before Listen reads them. func (h *Handshaker) OnRemoteAnswer(answer OfferAnswer) { + enqueueLatest(h.remoteAnswerCh, answer) +} + +// enqueueLatest delivers msg on a one-slot channel without blocking. When the slot +// already holds an unread message the older one is discarded in favor of msg, so a +// message arriving before Listen starts reading is held rather than dropped, and +// the newest wins if several arrive first. Safe because there is a single producer +// (the engine loop): after draining the stale value the send always has room. +func enqueueLatest(ch chan OfferAnswer, msg OfferAnswer) { select { - case h.remoteAnswerCh <- answer: + case ch <- msg: return default: - // connection might not be ready yet to receive so we ignore the message - h.log.Warnf("skipping remote answer message because receiver not ready") - return + } + + select { + case <-ch: + default: + } + + select { + case ch <- msg: + default: } } @@ -195,14 +209,14 @@ func (h *Handshaker) sendOffer() error { } offer := h.buildOfferAnswer() - h.log.Debugf("sending offer with serial: %s", offer.SessionIDString()) + h.log.Debugf("sending offer with serial: %s, relay server: %s, relay IP: %s", offer.SessionIDString(), offer.RelaySrvAddress, offer.RelaySrvIP) return h.signaler.SignalOffer(offer, h.config.Key) } func (h *Handshaker) sendAnswer() error { answer := h.buildOfferAnswer() - h.log.Debugf("sending answer with serial: %s", answer.SessionIDString()) + h.log.Debugf("sending answer with serial: %s, relay server: %s, relay IP: %s", answer.SessionIDString(), answer.RelaySrvAddress, answer.RelaySrvIP) return h.signaler.SignalAnswer(answer, h.config.Key) } diff --git a/client/internal/peer/handshaker_test.go b/client/internal/peer/handshaker_test.go new file mode 100644 index 000000000..5e203d78b --- /dev/null +++ b/client/internal/peer/handshaker_test.go @@ -0,0 +1,63 @@ +package peer + +import ( + "testing" + "time" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +func newTestHandshaker(t *testing.T) *Handshaker { + t.Helper() + // The tests exercise the answer path, whose Listen branch dispatches to the + // relay listener without sending an answer, so no signaler/ICE/relay is needed. + return NewHandshaker(log.WithField("test", t.Name()), ConnConfig{}, nil, nil, nil, nil) +} + +// TestHandshakerHoldsSignalArrivingBeforeListen covers the case where a peer is +// activated by an incoming signal: the remote's offer/answer arrives in the same +// step that opens the connection, before the Listen loop starts reading. The +// message must be held rather than dropped, or the connection cannot proceed until +// the remote re-sends. This is the path taken when an eager peer connects to a +// lazily-managed one. +func TestHandshakerHoldsSignalArrivingBeforeListen(t *testing.T) { + h := newTestHandshaker(t) + + processed := make(chan *OfferAnswer, 4) + h.AddRelayListener(func(o *OfferAnswer) { processed <- o }) + + // Delivered before Listen is reading, as when the peer is woken by the remote's + // signal and the message is delivered right after Open. + h.OnRemoteAnswer(OfferAnswer{WgListenPort: 51820}) + + go h.Listen(t.Context()) + + select { + case <-processed: + case <-time.After(2 * time.Second): + assert.Fail(t, "remote-answer dispatch: signal delivered before Listen was ready was dropped") + } +} + +// TestHandshakerKeepsLatestSignalBeforeListen covers several signals arriving +// before Listen reads: the newest must win (matching the latest-offer contract), +// rather than the first being kept and later ones discarded. +func TestHandshakerKeepsLatestSignalBeforeListen(t *testing.T) { + h := newTestHandshaker(t) + + processed := make(chan *OfferAnswer, 4) + h.AddRelayListener(func(o *OfferAnswer) { processed <- o }) + + h.OnRemoteAnswer(OfferAnswer{WgListenPort: 1111}) + h.OnRemoteAnswer(OfferAnswer{WgListenPort: 2222}) + + go h.Listen(t.Context()) + + select { + case got := <-processed: + assert.Equal(t, 2222, got.WgListenPort, "remote-answer dispatch: the latest queued signal should be processed") + case <-time.After(2 * time.Second): + assert.Fail(t, "remote-answer dispatch: queued signal was dropped") + } +} diff --git a/client/internal/peer/listener.go b/client/internal/peer/listener.go index c601fe534..2bb7fcf32 100644 --- a/client/internal/peer/listener.go +++ b/client/internal/peer/listener.go @@ -1,11 +1,40 @@ package peer +// ClientState identifies the client connection state delivered via +// Listener.OnStateChanged. +type ClientState int + +// Client states. The numeric values cross the gomobile boundary (the mobile +// bindings re-export them as integer constants), so they are a wire format: +// append new states at the end, never reorder or insert. +const ( + ClientStateDisconnected ClientState = iota + ClientStateConnected + ClientStateConnecting + ClientStateDisconnecting + // ClientStateNoNetwork is an overlay state: it is never stored as the + // last notification, only derived from ClientStateConnecting while the + // OS reports no usable network (see notifier.effectiveState). + ClientStateNoNetwork +) + // Listener is a callback type about the NetBird network connection state type Listener interface { + // OnStateChanged reports every client state transition. New states are + // delivered only through this callback; the per-state callbacks below + // are kept for compatibility and will be removed once all consumers + // have migrated. + OnStateChanged(state ClientState) + + // Deprecated: consume OnStateChanged instead. OnConnected() + // Deprecated: consume OnStateChanged instead. OnDisconnected() + // Deprecated: consume OnStateChanged instead. OnConnecting() + // Deprecated: consume OnStateChanged instead. OnDisconnecting() + OnAddressChanged(string, string) OnPeersListChanged(int) } diff --git a/client/internal/peer/notifier.go b/client/internal/peer/notifier.go index 8d1954fe5..1ee1d32ea 100644 --- a/client/internal/peer/notifier.go +++ b/client/internal/peer/notifier.go @@ -4,31 +4,64 @@ import ( "sync" ) -const ( - stateDisconnected = iota - stateConnected - stateConnecting - stateDisconnecting -) - type notifier struct { + // publishLock orders state publication: it is held across computing the + // effective state and handing it to the listener, so a transition cannot + // overtake a newer one and leave the listener on a stale state. + publishLock sync.Mutex serverStateLock sync.Mutex listenersLock sync.Mutex listener Listener currentClientState bool - lastNotification int + lastNotification ClientState lastNumberOfPeers int lastFqdnAddress string lastIPAddress string + networkAvailable bool } func newNotifier() *notifier { - return ¬ifier{} + return ¬ifier{ + networkAvailable: true, + } +} + +// effectiveState maps the computed state to what listeners should see: +// while the OS reports no usable network, "Connecting" would be a lie — +// connection attempts are suspended — so it is reported as NoNetwork. +// Caller must hold serverStateLock. +func (n *notifier) effectiveState(state ClientState) ClientState { + if !n.networkAvailable && state == ClientStateConnecting { + return ClientStateNoNetwork + } + return state +} + +// setNetworkAvailable records the OS network availability and re-notifies +// the listener when the flag flips the effective state (Connecting <-> +// NoNetwork). +func (n *notifier) setNetworkAvailable(available bool) { + n.publishLock.Lock() + defer n.publishLock.Unlock() + + n.serverStateLock.Lock() + if n.networkAvailable == available { + n.serverStateLock.Unlock() + return + } + previous := n.effectiveState(n.lastNotification) + n.networkAvailable = available + current := n.effectiveState(n.lastNotification) + n.serverStateLock.Unlock() + + if previous != current { + n.notify(current) + } } func (n *notifier) setListener(listener Listener) { n.serverStateLock.Lock() - lastNotification := n.lastNotification + lastNotification := n.effectiveState(n.lastNotification) numOfPeers := n.lastNumberOfPeers fqdnAddress := n.lastFqdnAddress address := n.lastIPAddress @@ -52,6 +85,9 @@ func (n *notifier) removeListener() { } func (n *notifier) updateServerStates(mgmState bool, signalState bool) { + n.publishLock.Lock() + defer n.publishLock.Unlock() + n.serverStateLock.Lock() calculatedState := n.calculateState(mgmState, signalState) @@ -61,43 +97,54 @@ func (n *notifier) updateServerStates(mgmState bool, signalState bool) { } n.lastNotification = calculatedState + effective := n.effectiveState(calculatedState) n.serverStateLock.Unlock() - n.notify(calculatedState) + n.notify(effective) } func (n *notifier) clientStart() { + n.publishLock.Lock() + defer n.publishLock.Unlock() + n.serverStateLock.Lock() n.currentClientState = true - n.lastNotification = stateConnecting + n.lastNotification = ClientStateConnecting + effective := n.effectiveState(ClientStateConnecting) n.serverStateLock.Unlock() - n.notify(stateConnecting) + n.notify(effective) } func (n *notifier) clientStop() { + n.publishLock.Lock() + defer n.publishLock.Unlock() + n.serverStateLock.Lock() n.currentClientState = false - n.lastNotification = stateDisconnected + n.lastNotification = ClientStateDisconnected n.serverStateLock.Unlock() - n.notify(stateDisconnected) + n.notify(ClientStateDisconnected) } func (n *notifier) clientTearDown() { + n.publishLock.Lock() + defer n.publishLock.Unlock() + n.serverStateLock.Lock() n.currentClientState = false - n.lastNotification = stateDisconnecting + n.lastNotification = ClientStateDisconnecting n.serverStateLock.Unlock() - n.notify(stateDisconnecting) + n.notify(ClientStateDisconnecting) } -func (n *notifier) isServerStateChanged(newState int) bool { +func (n *notifier) isServerStateChanged(newState ClientState) bool { return n.lastNotification != newState } -func (n *notifier) notify(state int) { +func (n *notifier) notify(state ClientState) { n.listenersLock.Lock() listener := n.listener n.listenersLock.Unlock() @@ -109,20 +156,20 @@ func (n *notifier) notify(state int) { notifyListener(listener, state) } -func (n *notifier) calculateState(managementConn, signalConn bool) int { +func (n *notifier) calculateState(managementConn, signalConn bool) ClientState { if managementConn && signalConn { - return stateConnected + return ClientStateConnected } if !managementConn && !signalConn && !n.currentClientState { - return stateDisconnected + return ClientStateDisconnected } - if n.lastNotification == stateDisconnecting { - return stateDisconnecting + if n.lastNotification == ClientStateDisconnecting { + return ClientStateDisconnecting } - return stateConnecting + return ClientStateConnecting } func (n *notifier) peerListChanged(numOfPeers int) { @@ -159,15 +206,19 @@ func (n *notifier) localAddressChanged(fqdn, address string) { listener.OnAddressChanged(fqdn, address) } -func notifyListener(l Listener, state int) { +func notifyListener(l Listener, state ClientState) { + // legacy per-state callbacks; NoNetwork is delivered only via + // OnStateChanged below switch state { - case stateDisconnected: + case ClientStateDisconnected: l.OnDisconnected() - case stateConnected: + case ClientStateConnected: l.OnConnected() - case stateConnecting: + case ClientStateConnecting: l.OnConnecting() - case stateDisconnecting: + case ClientStateDisconnecting: l.OnDisconnecting() } + + l.OnStateChanged(state) } diff --git a/client/internal/peer/notifier_concurrent_test.go b/client/internal/peer/notifier_concurrent_test.go new file mode 100644 index 000000000..fcaaaad3b --- /dev/null +++ b/client/internal/peer/notifier_concurrent_test.go @@ -0,0 +1,108 @@ +package peer + +import ( + "sync" + "testing" + "time" +) + +type recordingListener struct { + mu sync.Mutex + states []ClientState + onState func(ClientState) +} + +func (l *recordingListener) OnStateChanged(state ClientState) { + l.mu.Lock() + l.states = append(l.states, state) + hook := l.onState + l.mu.Unlock() + + if hook != nil { + hook(state) + } +} + +func (l *recordingListener) last() (ClientState, bool) { + l.mu.Lock() + defer l.mu.Unlock() + if len(l.states) == 0 { + return 0, false + } + return l.states[len(l.states)-1], true +} + +func (l *recordingListener) snapshot() []ClientState { + l.mu.Lock() + defer l.mu.Unlock() + return append([]ClientState(nil), l.states...) +} + +func (l *recordingListener) OnConnected() {} +func (l *recordingListener) OnDisconnected() {} +func (l *recordingListener) OnConnecting() {} +func (l *recordingListener) OnDisconnecting() {} +func (l *recordingListener) OnAddressChanged(string, string) {} +func (l *recordingListener) OnPeersListChanged(int) {} + +// TestNotifier_ConcurrentAvailabilityFlipOrdersPublication holds the first +// transition inside the listener callback and flips availability again from +// another goroutine while it is parked. The second flip must not publish +// ahead of the one in flight, otherwise the listener ends up on a state the +// notifier already superseded. +func TestNotifier_ConcurrentAvailabilityFlipOrdersPublication(t *testing.T) { + n := newNotifier() + n.currentClientState = true + n.lastNotification = ClientStateConnecting + + entered := make(chan struct{}) + release := make(chan struct{}) + + l := &recordingListener{} + l.onState = func(state ClientState) { + if state != ClientStateNoNetwork { + return + } + l.mu.Lock() + l.onState = nil + l.mu.Unlock() + close(entered) + <-release + } + n.listener = l + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + n.setNetworkAvailable(false) + }() + + <-entered + + flipped := make(chan struct{}) + go func() { + defer close(flipped) + n.setNetworkAvailable(true) + }() + + select { + case <-flipped: + t.Fatal("the online transition published while the offline one was " + + "still in flight; publication is not serialized") + case <-time.After(200 * time.Millisecond): + } + + close(release) + <-flipped + wg.Wait() + + got, ok := l.last() + if !ok { + t.Fatal("listener never observed a state") + } + if got != ClientStateConnecting { + t.Fatalf("listener holds %v after the network came back, want Connecting; sequence: %v", + got, l.snapshot()) + } +} diff --git a/client/internal/peer/notifier_test.go b/client/internal/peer/notifier_test.go index 0b7722b0c..a73016b05 100644 --- a/client/internal/peer/notifier_test.go +++ b/client/internal/peer/notifier_test.go @@ -6,29 +6,32 @@ import ( ) type mocListener struct { - lastState int + lastState ClientState wg sync.WaitGroup peersWg sync.WaitGroup peers int } func (l *mocListener) OnConnected() { - l.lastState = stateConnected + l.lastState = ClientStateConnected l.wg.Done() } func (l *mocListener) OnDisconnected() { - l.lastState = stateDisconnected + l.lastState = ClientStateDisconnected l.wg.Done() } func (l *mocListener) OnConnecting() { - l.lastState = stateConnecting + l.lastState = ClientStateConnecting l.wg.Done() } func (l *mocListener) OnDisconnecting() { - l.lastState = stateDisconnecting + l.lastState = ClientStateDisconnecting l.wg.Done() } +func (l *mocListener) OnStateChanged(state ClientState) { + +} func (l *mocListener) OnAddressChanged(host, addr string) { } @@ -57,15 +60,15 @@ func Test_notifier_serverState(t *testing.T) { type scenario struct { name string - expected int + expected ClientState mgmState bool signalState bool } scenarios := []scenario{ - {"connected", stateConnected, true, true}, - {"mgm down", stateConnecting, false, true}, - {"signal down", stateConnecting, true, false}, - {"disconnected", stateDisconnected, false, false}, + {"connected", ClientStateConnected, true, true}, + {"mgm down", ClientStateConnecting, false, true}, + {"signal down", ClientStateConnecting, true, false}, + {"disconnected", ClientStateDisconnected, false, false}, } for _, tt := range scenarios { @@ -85,7 +88,7 @@ func Test_notifier_SetListener(t *testing.T) { listener.setPeersWaiter() n := newNotifier() - n.lastNotification = stateConnecting + n.lastNotification = ClientStateConnecting n.setListener(listener) listener.wait() listener.waitPeers() @@ -99,7 +102,7 @@ func Test_notifier_RemoveListener(t *testing.T) { listener.setWaiter() listener.setPeersWaiter() n := newNotifier() - n.lastNotification = stateConnecting + n.lastNotification = ClientStateConnecting n.setListener(listener) // setListener replays cached state on a goroutine; wait for both the state // and peers callbacks to finish so we don't race on listener.peers. diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go index a987482fe..d753ee43e 100644 --- a/client/internal/peer/status.go +++ b/client/internal/peer/status.go @@ -813,19 +813,14 @@ func (d *Status) SetSessionExpiresAt(deadline time.Time) { } // GetSessionExpiresAt returns the most recently recorded SSO session deadline, -// or the zero value when no deadline is tracked. A deadline that has already -// slipped into the past reports as "none": once the session has expired it is -// no longer a meaningful countdown, and the sessionwatch.Watcher does not -// arm a timer at the deadline itself to clear it (only the two pre-expiry -// warnings). Without this guard the UI would keep painting a stale -// "expires in …" against a moment that has passed until the next login, -// extend, or teardown rewrote the value. +// or the zero value when no deadline is tracked. A deadline in the past is +// returned as-is: it means the session has expired, and consumers (tray row, +// CLI status) render it as "expired" rather than hiding it — masking it as +// "none" would blank the UI at the exact moment it should say the session +// ended. func (d *Status) GetSessionExpiresAt() time.Time { - d.mux.Lock() - defer d.mux.Unlock() - if !d.sessionExpiresAt.IsZero() && d.sessionExpiresAt.Before(time.Now()) { - return time.Time{} - } + d.mux.RLock() + defer d.mux.RUnlock() return d.sessionExpiresAt } @@ -1172,6 +1167,18 @@ func (d *Status) GetResolvedDomainsStates() map[domain.Domain]ResolvedDomainInfo return maps.Clone(d.resolvedDomainsStates) } +// GetPeerStates returns a snapshot of all known peer states, including offline peers. +func (d *Status) GetPeerStates() []State { + d.mux.RLock() + defer d.mux.RUnlock() + + states := make([]State, 0, d.numOfPeers()) + for _, state := range d.peers { + states = append(states, state) + } + return append(states, d.offlinePeers...) +} + // GetFullStatus gets full status func (d *Status) GetFullStatus() FullStatus { fullStatus := FullStatus{ @@ -1216,6 +1223,12 @@ func (d *Status) ClientTeardown() { d.notifyStateChange() } +// SetNetworkAvailable records the OS-reported network availability; while +// unavailable, listeners see NoNetwork instead of Connecting. +func (d *Status) SetNetworkAvailable(available bool) { + d.notifier.setNetworkAvailable(available) +} + // SetConnectionListener set a listener to the notifier func (d *Status) SetConnectionListener(listener Listener) { d.notifier.setListener(listener) diff --git a/client/internal/peer/status_test.go b/client/internal/peer/status_test.go index 29404d413..82dff0d6f 100644 --- a/client/internal/peer/status_test.go +++ b/client/internal/peer/status_test.go @@ -129,6 +129,28 @@ func TestStatus_PeerStateByIP_RemovedPeer(t *testing.T) { req.False(ok, "removed peer must not resolve by IPv6 tunnel address") } +// TestStatus_GetPeerStates_IncludesOfflinePeers keeps the snapshot in line with +// GetFullStatus: offline peers are known peers, so a consumer counting peers +// must see the same total the status command reports. +func TestStatus_GetPeerStates_IncludesOfflinePeers(t *testing.T) { + status := NewRecorder("https://mgm") + req := require.New(t) + + req.NoError(status.AddPeer("pk-online", "online.netbird", "100.64.0.10", "fd00::1")) + status.ReplaceOfflinePeers([]State{ + {PubKey: "pk-offline", FQDN: "offline.netbird", IP: "100.64.0.20", ConnStatus: StatusIdle}, + }) + + states := status.GetPeerStates() + req.Len(states, 2, "snapshot must carry both the online and the offline peer") + + keys := make([]string, 0, len(states)) + for _, s := range states { + keys = append(keys, s.PubKey) + } + req.ElementsMatch([]string{"pk-online", "pk-offline"}, keys, "snapshot must carry both peers") +} + func TestStatus_UpdatePeerFQDN(t *testing.T) { key := "abc" fqdn := "peer-a.netbird.local" diff --git a/client/internal/peer/worker_ice.go b/client/internal/peer/worker_ice.go index b1aa3e0f9..d17f6e693 100644 --- a/client/internal/peer/worker_ice.go +++ b/client/internal/peer/worker_ice.go @@ -64,6 +64,9 @@ type WorkerICE struct { // portForwardAttempted tracks if we've already tried port forwarding this session portForwardAttempted bool + + // dialFunc, when non-nil, replaces agentDial in connect(). Only for tests. + dialFunc func(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) (net.Conn, error) } func NewWorkerICE(ctx context.Context, log *log.Entry, config ConnConfig, conn *Conn, signaler *Signaler, ifaceDiscover stdnet.ExternalIFaceDiscover, statusRecorder *Status, hasRelayOnLocally bool) (*WorkerICE, error) { @@ -123,7 +126,7 @@ func (w *WorkerICE) OnNewOffer(remoteOfferAnswer *OfferAnswer) { w.log.Errorf("failed to create new session ID: %s", err) } w.sessionID = sessionID - w.agent = nil + w.abandonNegotiation() } var preferredCandidateTypes []ice.CandidateType @@ -151,7 +154,9 @@ func (w *WorkerICE) OnNewOffer(remoteOfferAnswer *OfferAnswer) { w.remoteSessionID = "" } - go w.connect(dialerCtx, agent, remoteOfferAnswer) + // Capture the cancel func at spawn time: connect reads it from the argument + // instead of the field, which a newer OnNewOffer may already have replaced. + go w.connect(dialerCtx, dialerCancel, agent, remoteOfferAnswer) } // OnRemoteCandidate Handles ICE connection Candidate provided by the remote peer. @@ -200,16 +205,16 @@ func (w *WorkerICE) Close() { w.muxAgent.Lock() defer w.muxAgent.Unlock() - if w.agent == nil { - return + if w.agent != nil { + w.agentDialerCancel() + if err := w.agent.Close(); err != nil { + w.log.Warnf("failed to close ICE agent: %s", err) + } } - - w.agentDialerCancel() - if err := w.agent.Close(); err != nil { - w.log.Warnf("failed to close ICE agent: %s", err) - } - - w.agent = nil + // Unconditional: a dial goroutine racing this Close skips its own cleanup + // (closeAgent finds a nil agent), so the flags must be dropped here too or + // the reconnection guard reads the stale state as Connected forever. + w.abandonNegotiation() } func (w *WorkerICE) reCreateAgent(dialerCancel context.CancelFunc, candidates []ice.CandidateType) (*icemaker.ThreadSafeAgent, error) { @@ -247,31 +252,52 @@ func (w *WorkerICE) SessionID() ICESessionID { // will block until connection succeeded // but it won't release if ICE Agent went into Disconnected or Failed state, // so we have to cancel it with the provided context once agent detected a broken connection -func (w *WorkerICE) connect(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) { +func (w *WorkerICE) connect(ctx context.Context, dialerCancel context.CancelFunc, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) { w.log.Debugf("gather candidates") if err := agent.GatherCandidates(); err != nil { w.log.Warnf("failed to gather candidates: %s", err) - w.closeAgent(agent, w.agentDialerCancel) + w.closeAgent(agent, dialerCancel) return } - w.log.Debugf("turn agent dial") - remoteConn, err := w.turnAgentDial(ctx, agent, remoteOfferAnswer) + w.log.Debugf("agent dial") + dial := func(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) (net.Conn, error) { + return w.agentDial(ctx, agent, remoteOfferAnswer) + } + if w.dialFunc != nil { + dial = w.dialFunc + } + remoteConn, err := dial(ctx, agent, remoteOfferAnswer) if err != nil { w.log.Debugf("failed to dial the remote peer: %s", err) - w.closeAgent(agent, w.agentDialerCancel) + w.closeAgent(agent, dialerCancel) return } w.log.Debugf("agent dial succeeded") + // A newer negotiation may have replaced our agent while agentDial was + // blocked. Drop the dead connection before running pair retrieval, port + // punching or candidate work against a closed agent. The commit-point + // check below still guards a replacement arriving after this point. + w.muxAgent.Lock() + stale := w.agent != agent + w.muxAgent.Unlock() + if stale { + if err := remoteConn.Close(); err != nil { + w.log.Warnf("failed to close stale ICE connection: %s", err) + } + w.log.Warnf("discarding connection from a stale ICE negotiation") + return + } + pair, err := agent.GetSelectedCandidatePair() if err != nil { - w.closeAgent(agent, w.agentDialerCancel) + w.closeAgent(agent, dialerCancel) return } if pair == nil { w.log.Warnf("selected candidate pair is nil, cannot proceed") - w.closeAgent(agent, w.agentDialerCancel) + w.closeAgent(agent, dialerCancel) return } @@ -301,11 +327,27 @@ func (w *WorkerICE) connect(ctx context.Context, agent *icemaker.ThreadSafeAgent w.log.Infof("connection succeeded with offer session: %s", remoteOfferAnswer.SessionIDString()) w.muxAgent.Lock() + // Authoritative ownership guard: a negotiation that lost w.agent to a newer + // one between the post-dial check and the commit must not clear agentConnecting, + // record lastSuccess or report the connection, so the state commit has to be + // atomic with the check. + if w.agent != agent { + w.muxAgent.Unlock() + if err := remoteConn.Close(); err != nil { + w.log.Warnf("failed to close stale ICE connection: %s", err) + } + w.log.Warnf("discarding connection from a stale ICE negotiation") + return + } w.agentConnecting = false w.lastSuccess = time.Now() w.muxAgent.Unlock() // todo: the potential problem is a race between the onConnectionStateChange + // and the delivery below: after this unlock, a newer offer can replace + // w.agent before onICEConnectionIsReady runs, delivering this (now stale) + // connection. The newer negotiation overwrites it with its own delivery, + // so the window only ever downgrades an endpoint transiently. w.conn.onICEConnectionIsReady(selectedPriority(pair), ci) } @@ -321,20 +363,32 @@ func (w *WorkerICE) closeAgent(agent *icemaker.ThreadSafeAgent, cancel context.C sessionChanged := w.remoteSessionChanged w.remoteSessionChanged = false + // Only the owner of the current session may reset its state: a stale dial + // goroutine waking after a newer attempt must not clobber it. if w.agent == agent { - // consider to remove from here and move to the OnNewOffer sessionID, err := NewICESessionID() if err != nil { w.log.Errorf("failed to create new session ID: %s", err) } w.sessionID = sessionID - w.agent = nil - w.agentConnecting = false - w.remoteSessionID = "" + w.abandonNegotiation() } return sessionChanged } +// abandonNegotiation drops all recorded ICE session state so the worker treats the +// next offer as a fresh start instead of a duplicate of a dead negotiation. The +// agent and agentConnecting flags must change together: leaving one stale wedges +// the reconnection guard into reporting Connected forever. It neither cancels an +// in-flight dial nor closes an agent — callers dispose of those themselves first, +// so a stale goroutine can never cancel another session's dial through this path. +// Caller must hold muxAgent. +func (w *WorkerICE) abandonNegotiation() { + w.agent = nil + w.agentConnecting = false + w.remoteSessionID = "" +} + func (w *WorkerICE) punchRemoteWGPort(pair *ice.CandidatePair, remoteWgPort int) { // wait local endpoint configuration time.Sleep(time.Second) @@ -389,6 +443,17 @@ func (w *WorkerICE) injectPortForwardedCandidate(srflxCandidate ice.Candidate) { return } + // A forwarded candidate only makes sense for an IPv4 mapping, which + // translates a port on the gateway's address. An IPv6 pinhole translates + // nothing: it unblocks the address ICE already gathers as a host candidate, + // so there is no second address to advertise. Injecting one here would also + // paste an IPv6 address onto whichever server-reflexive candidate arrived + // first, which is usually IPv4. + if mapping.ExternalIP != nil && mapping.ExternalIP.To4() == nil { + w.log.Debugf("skipping port-forwarded candidate: %s mapping is IPv6-only", mapping.NATType) + return + } + w.muxAgent.Lock() if w.portForwardAttempted { w.muxAgent.Unlock() @@ -517,8 +582,8 @@ func (w *WorkerICE) onConnectionStateChange(agent *icemaker.ThreadSafeAgent, dia w.logSuccessfulPaths(agent) return case ice.ConnectionStateFailed, ice.ConnectionStateDisconnected, ice.ConnectionStateClosed: - // ice.ConnectionStateClosed happens when we recreate the agent. For the P2P to TURN switch important to - // notify the conn.onICEStateDisconnected changes to update the current used priority + // ice.ConnectionStateClosed happens when we recreate the agent. The P2P to relay switch requires + // notifying conn.onICEStateDisconnected so it can update the currently used priority. sessionChanged := w.closeAgent(agent, dialerCancel) @@ -532,7 +597,7 @@ func (w *WorkerICE) onConnectionStateChange(agent *icemaker.ThreadSafeAgent, dia } } -func (w *WorkerICE) turnAgentDial(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) (*ice.Conn, error) { +func (w *WorkerICE) agentDial(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) (*ice.Conn, error) { if isController(w.config) { return agent.Dial(ctx, remoteOfferAnswer.IceCredentials.UFrag, remoteOfferAnswer.IceCredentials.Pwd) } else { diff --git a/client/internal/peer/worker_ice_close_test.go b/client/internal/peer/worker_ice_close_test.go new file mode 100644 index 000000000..834a4dd6d --- /dev/null +++ b/client/internal/peer/worker_ice_close_test.go @@ -0,0 +1,257 @@ +package peer + +import ( + "context" + "net" + "sync/atomic" + "testing" + "time" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + + icemaker "github.com/netbirdio/netbird/client/internal/peer/ice" + signal "github.com/netbirdio/netbird/shared/signal/client" + sProto "github.com/netbirdio/netbird/shared/signal/proto" +) + +// stubSignalClient satisfies signal.Client as a no-op so the candidate +// goroutine spawned by a real GatherCandidates never dereferences a nil +// signaler in tests. +type stubSignalClient struct{} + +func (stubSignalClient) Close() error { return nil } +func (stubSignalClient) StreamConnected() bool { return false } +func (stubSignalClient) GetStatus() signal.Status { return signal.StreamDisconnected } +func (stubSignalClient) Receive(context.Context, func(*sProto.Message) error) error { return nil } +func (stubSignalClient) Ready() bool { return false } +func (stubSignalClient) IsHealthy() bool { return false } +func (stubSignalClient) WaitStreamConnected(context.Context) {} +func (stubSignalClient) SendToStream(*sProto.EncryptedMessage) error { return nil } +func (stubSignalClient) Send(*sProto.Message) error { return nil } +func (stubSignalClient) SetOnReconnectedListener(func()) {} + +// newTestWorkerICE builds a worker with real pion plumbing and no-op signaling. +func newTestWorkerICE(t *testing.T) *WorkerICE { + t.Helper() + + config := connConf + stunTurn := &icemaker.StunTurn{} + stunTurn.Store(nil) + config.ICEConfig.StunTurn = stunTurn + + w, err := NewWorkerICE(context.Background(), log.WithField("test", t.Name()), config, nil, + NewSignaler(stubSignalClient{}, wgtypes.Key{}), nil, nil, false) + require.NoError(t, err, "worker setup must succeed") + return w +} + +// TestWorkerICE_CloseDuringDial_ClearsConnectingFlag drives the teardown race +// through the real dial goroutine instead of simulating its cleanup. +// +// The real-world sequence this models: +// 1. OnNewOffer starts a negotiation: agent set, agentConnecting = true, +// go connect() +// 2. The network dies and connect() stays blocked inside GatherCandidates/Dial +// 3. A WG handshake timeout calls Close(): the agent is released and the dial +// context cancelled, but agentConnecting is not reset +// 4. The real goroutine wakes with an error and runs its own cleanup +// (closeAgent), where `w.agent == agent` is now false, so the flag reset +// is skipped +// +// There is no remote responder, so Dial can never succeed: whatever point the +// goroutine is at, closing first forces it down the error path. Before the fix +// the flag stays true forever and the deadline below expires. +func TestWorkerICE_CloseDuringDial_ClearsConnectingFlag(t *testing.T) { + w := newTestWorkerICE(t) + + sid := ICESessionID("test-session-id") + w.OnNewOffer(&OfferAnswer{ + IceCredentials: IceCredentials{ + UFrag: "testufrag", + Pwd: "testpwdtestpwdtestpwd12", + }, + SessionID: &sid, + }) + require.True(t, w.InProgress(), "OnNewOffer must mark the negotiation as in progress") + + // Teardown wins the race while connect() is still running. + w.Close() + + // Close drops the flags synchronously, so the assertion below does not + // converge on the goroutine: the deadline only absorbs the dial goroutine + // waking up in the background, proving nothing re-wedges it afterwards. + require.Eventually(t, func() bool { + return !w.InProgress() + }, 10*time.Second, 50*time.Millisecond, + "Close must leave the negotiation idle even while the dial goroutine is still winding down") + + // abandonNegotiation owns these three fields together; the worker is idle + // only when all of them are dropped. + w.muxAgent.Lock() + defer w.muxAgent.Unlock() + assert.Nil(t, w.agent, "no agent may survive the teardown") + assert.False(t, w.agentConnecting, "the connecting flag must match the nil agent") + assert.Empty(t, w.remoteSessionID, "a dead session's remote ID must not linger") +} + +// TestWorkerICE_CloseClearsResidualConnectingState covers Close on a worker whose +// agent is already gone but whose flag is stuck on true, e.g. after an aborted +// recreate in OnNewOffer or after a first Close raced a dial goroutine. +func TestWorkerICE_CloseClearsResidualConnectingState(t *testing.T) { + w := newTestWorkerICE(t) + + w.muxAgent.Lock() + w.agentConnecting = true + w.muxAgent.Unlock() + + w.Close() + + assert.False(t, w.InProgress(), "Close must drop residual connecting state even without a live agent") + + w.muxAgent.Lock() + defer w.muxAgent.Unlock() + assert.Nil(t, w.agent) + assert.False(t, w.agentConnecting) + assert.Empty(t, w.remoteSessionID) +} + +// TestWorkerICE_StaleCloseAgentKeepsCurrentSession pins the ownership guard in +// closeAgent: a late-waking dial goroutine from an older session must not reset +// the state of a newer negotiation that reused the worker. The newer session +// must survive wholesale - agent, flag and remote session identity alike. +func TestWorkerICE_StaleCloseAgentKeepsCurrentSession(t *testing.T) { + w := newTestWorkerICE(t) + t.Cleanup(w.Close) + + sidA := ICESessionID("session-a") + w.OnNewOffer(&OfferAnswer{ + IceCredentials: IceCredentials{UFrag: "ufragaaaa", Pwd: "pwdpwdpwdpwdpwdpwdpwdp1"}, + SessionID: &sidA, + }) + w.muxAgent.Lock() + oldAgent := w.agent + oldCancel := w.agentDialerCancel + w.muxAgent.Unlock() + require.NotNil(t, oldAgent, "OnNewOffer must have created an ICE agent") + + w.Close() + + sidB := ICESessionID("session-b") + w.OnNewOffer(&OfferAnswer{ + IceCredentials: IceCredentials{UFrag: "ufragbbbb", Pwd: "pwdpwdpwdpwdpwdpwdpwdp2"}, + SessionID: &sidB, + }) + require.True(t, w.InProgress(), "the second negotiation must be in flight") + + w.muxAgent.Lock() + newAgent := w.agent + w.muxAgent.Unlock() + + // The old dial goroutine finally wakes and cleans up its captured agent. + w.closeAgent(oldAgent, oldCancel) + + w.muxAgent.Lock() + defer w.muxAgent.Unlock() + assert.Same(t, newAgent, w.agent, "the current agent must be untouched by the stale cleanup") + assert.True(t, w.agentConnecting, "the current negotiation must stay in flight") + // Read live under the lock: a snapshot captured before the stale cleanup + // would pass even if the cleanup wiped current state. + assert.Equal(t, sidB, w.remoteSessionID, "the remote session identity must be preserved") +} + +// closeTrackConn records Close calls so a test can assert that a discarded +// connection was actually released. +type closeTrackConn struct { + net.Conn + closed atomic.Bool +} + +func (c *closeTrackConn) Close() error { + c.closed.Store(true) + return c.Conn.Close() +} + +// TestWorkerICE_StaleDialSuccessKeepsNewerNegotiation pins the ownership guard +// in connect()'s success path: a dial that came back after a newer negotiation +// replaced the agent must discard its connection and leave the newer session's +// state - agent, agentConnecting, remoteSessionID, lastSuccess - intact. +// +// The dial hook holds session A's goroutine open until session B is installed, +// then returns a live connection, mimicking the vendored pion dial which hands +// out a live *ice.Conn when a pair is selected without checking afterwards +// whether the agent was replaced meanwhile. Releasing A's dial therefore +// exercises the stale-success commit path deterministically instead of racing +// real ICE. +func TestWorkerICE_StaleDialSuccessKeepsNewerNegotiation(t *testing.T) { + w := newTestWorkerICE(t) + t.Cleanup(w.Close) + + dialStarted := make(chan struct{}) + releaseDial := make(chan struct{}) + staleConn := &closeTrackConn{} + + var calls atomic.Int32 + w.dialFunc = func(ctx context.Context, _ *icemaker.ThreadSafeAgent, _ *OfferAnswer) (net.Conn, error) { + if calls.Add(1) == 1 { + // Session A: hold the goroutine open until session B is installed, + // then return a live connection, mimicking the vendored pion dial + // which hands out a live *ice.Conn once a pair is selected without + // re-checking whether the agent was replaced meanwhile. Releasing + // the dial therefore exercises the stale-success commit path + // deterministically instead of racing real ICE. + close(dialStarted) + <-releaseDial + client, _ := net.Pipe() + staleConn.Conn = client + return staleConn, nil + } + // A newer negotiation parks on its dialer context, cancelled by the + // t.Cleanup Close at test end. + <-ctx.Done() + return nil, ctx.Err() + } + + sidA := ICESessionID("session-a") + w.OnNewOffer(&OfferAnswer{ + IceCredentials: IceCredentials{UFrag: "ufragaaaa", Pwd: "pwdpwdpwdpwdpwdpwdpwdp1"}, + SessionID: &sidA, + }) + require.True(t, w.InProgress(), "session A must be in flight") + + // Session A's goroutine is now parked in the dial hook. + <-dialStarted + + sidB := ICESessionID("session-b") + w.OnNewOffer(&OfferAnswer{ + IceCredentials: IceCredentials{UFrag: "ufragbbbb", Pwd: "pwdpwdpwdpwdpwdpwdpwdp2"}, + SessionID: &sidB, + }) + + w.muxAgent.Lock() + agentB := w.agent + w.lastSuccess = time.Time{} + w.muxAgent.Unlock() + require.NotNil(t, agentB, "session B must have created an ICE agent") + require.True(t, w.InProgress(), "session B must be in flight") + + // Release session A's dial: it must be recognized as stale and discarded. + close(releaseDial) + require.Eventually(t, func() bool { + return staleConn.closed.Load() + }, 10*time.Second, 10*time.Millisecond, + "the stale connection must be closed by the ownership guard") + + w.muxAgent.Lock() + defer w.muxAgent.Unlock() + assert.Same(t, agentB, w.agent, "session A must not uninstall session B's agent") + assert.True(t, w.agentConnecting, "session A must not clear session B's connecting flag") + assert.Equal(t, sidB, w.remoteSessionID, "session A must not clear session B's remote session identity") + assert.True(t, w.lastSuccess.IsZero(), "session A must not record a success for session B") + // The commit block guards agentConnecting, lastSuccess and + // onICEConnectionIsReady together, so the state assertions above imply the + // callback never ran for session A; the nil conn would have panicked the + // stale goroutine on any invocation. +} diff --git a/client/internal/peer/worker_relay.go b/client/internal/peer/worker_relay.go index 0402992c9..fc3489992 100644 --- a/client/internal/peer/worker_relay.go +++ b/client/internal/peer/worker_relay.go @@ -3,7 +3,6 @@ package peer import ( "context" "errors" - "net" "net/netip" "sync" "sync/atomic" @@ -14,7 +13,7 @@ import ( ) type RelayConnInfo struct { - relayedConn net.Conn + relayedConn *relayClient.Conn rosenpassPubKey []byte rosenpassAddr string } @@ -27,7 +26,7 @@ type WorkerRelay struct { conn *Conn relayManager *relayClient.Manager - relayedConn net.Conn + relayedConn *relayClient.Conn relayLock sync.Mutex relaySupportedOnRemotePeer atomic.Bool @@ -80,12 +79,7 @@ func (w *WorkerRelay) OnNewOffer(remoteOfferAnswer *OfferAnswer) { w.relayedConn = relayedConn w.relayLock.Unlock() - err = w.relayManager.AddCloseListener(srv, w.onRelayClientDisconnected) - if err != nil { - log.Errorf("failed to add close listener: %s", err) - _ = relayedConn.Close() - return - } + go w.watchRelayedConn(relayedConn) w.log.Debugf("peer conn opened via Relay: %s", srv) go w.conn.onRelayConnectionIsReady(RelayConnInfo{ @@ -109,12 +103,15 @@ func (w *WorkerRelay) RelayIsSupportedLocally() bool { func (w *WorkerRelay) CloseConn() { w.relayLock.Lock() - defer w.relayLock.Unlock() - if w.relayedConn == nil { + conn := w.relayedConn + w.relayedConn = nil + w.relayLock.Unlock() + + if conn == nil { return } - if err := w.relayedConn.Close(); err != nil { + if err := conn.Close(); err != nil { w.log.Warnf("failed to close relay connection: %v", err) } } @@ -133,6 +130,8 @@ func (w *WorkerRelay) preferredRelayServer(myRelayAddress, remoteRelayAddress st return remoteRelayAddress } -func (w *WorkerRelay) onRelayClientDisconnected() { - go w.conn.onRelayDisconnected() +func (w *WorkerRelay) watchRelayedConn(relayedConn *relayClient.Conn) { + <-relayedConn.Context().Done() + + w.conn.onRelayDisconnected(relayedConn) } diff --git a/client/internal/portforward/manager.go b/client/internal/portforward/manager.go index b0680160c..7d5a4cb9e 100644 --- a/client/internal/portforward/manager.go +++ b/client/internal/portforward/manager.go @@ -10,10 +10,8 @@ import ( "sync" "time" - "github.com/libp2p/go-nat" + "github.com/netbirdio/go-nat" log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/internal/portforward/pcp" ) const ( @@ -168,6 +166,11 @@ func (m *Manager) setup(ctx context.Context) (nat.NAT, *Mapping, error) { if err != nil { return nil, nil, fmt.Errorf("create port mapping: %w", err) } + + // Only meaningful once a mapping has been attempted: that is what opens the + // pinhole and records its outcome. + logIPv6Pinhole(gateway) + return gateway, mapping, nil } @@ -265,7 +268,9 @@ func (m *Manager) checkHealthAndRecreate(ctx context.Context, gateway nat.NAT) b return false } - pcpNAT, ok := gateway.(*pcp.NAT) + // Assert on the interface, not on a concrete type: a dual-stack gateway is + // a wrapper around the IPv4 NAT, so a type assertion misses it. + checker, ok := gateway.(nat.HealthChecker) if !ok { return false } @@ -273,7 +278,7 @@ func (m *Manager) checkHealthAndRecreate(ctx context.Context, gateway nat.NAT) b ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - epoch, serverRestarted, err := pcpNAT.CheckServerHealth(ctx) + epoch, serverRestarted, err := checker.CheckServerHealth(ctx) if err != nil { log.Debugf("PCP health check failed: %v", err) return false @@ -340,3 +345,18 @@ func (m *Manager) startTearDown(ctx context.Context) { func isPermanentLeaseRequired(err error) bool { return err != nil && upnpErrPermanentLeaseOnly.MatchString(err.Error()) } + +// logIPv6Pinhole reports the outcome of the IPv6 pinhole. Pinholes are best +// effort and never fail a mapping on their own, so this is the only way to see +// whether one was actually opened. +func logIPv6Pinhole(gateway nat.NAT) { + reporter, ok := gateway.(nat.IPv6PinholeReporter) + if !ok { + return + } + if err := reporter.IPv6PinholeError(); err != nil { + log.Warnf("IPv6 pinhole: %v", err) + return + } + log.Infof("IPv6 pinhole open") +} diff --git a/client/internal/portforward/pcp/client.go b/client/internal/portforward/pcp/client.go deleted file mode 100644 index f6d243ef9..000000000 --- a/client/internal/portforward/pcp/client.go +++ /dev/null @@ -1,408 +0,0 @@ -package pcp - -import ( - "context" - "crypto/rand" - "errors" - "fmt" - "net" - "net/netip" - "sync" - "time" - - log "github.com/sirupsen/logrus" -) - -const ( - defaultTimeout = 3 * time.Second - responseBufferSize = 128 - - // RFC 6887 Section 8.1.1 retry timing - initialRetryDelay = 3 * time.Second - maxRetryDelay = 1024 * time.Second - maxRetries = 4 // 3s + 6s + 12s + 24s = 45s total worst case -) - -// Client is a PCP protocol client. -// All methods are safe for concurrent use. -type Client struct { - gateway netip.Addr - timeout time.Duration - - mu sync.Mutex - // localIP caches the resolved local IP address. - localIP netip.Addr - // lastEpoch is the last observed server epoch value. - lastEpoch uint32 - // epochTime tracks when lastEpoch was received for state loss detection. - epochTime time.Time - // externalIP caches the external IP from the last successful MAP response. - externalIP netip.Addr - // epochStateLost is set when epoch indicates server restart. - epochStateLost bool -} - -// NewClient creates a new PCP client for the gateway at the given IP. -func NewClient(gateway net.IP) *Client { - addr, ok := netip.AddrFromSlice(gateway) - if !ok { - log.Debugf("invalid gateway IP: %v", gateway) - } - return &Client{ - gateway: addr.Unmap(), - timeout: defaultTimeout, - } -} - -// NewClientWithTimeout creates a new PCP client with a custom timeout. -func NewClientWithTimeout(gateway net.IP, timeout time.Duration) *Client { - addr, ok := netip.AddrFromSlice(gateway) - if !ok { - log.Debugf("invalid gateway IP: %v", gateway) - } - return &Client{ - gateway: addr.Unmap(), - timeout: timeout, - } -} - -// SetLocalIP sets the local IP address to use in PCP requests. -func (c *Client) SetLocalIP(ip net.IP) { - addr, ok := netip.AddrFromSlice(ip) - if !ok { - log.Debugf("invalid local IP: %v", ip) - } - c.mu.Lock() - c.localIP = addr.Unmap() - c.mu.Unlock() -} - -// Gateway returns the gateway IP address. -func (c *Client) Gateway() net.IP { - return c.gateway.AsSlice() -} - -// Announce sends a PCP ANNOUNCE request to discover PCP support. -// Returns the server's epoch time on success. -func (c *Client) Announce(ctx context.Context) (epoch uint32, err error) { - localIP, err := c.getLocalIP() - if err != nil { - return 0, fmt.Errorf("get local IP: %w", err) - } - - req := buildAnnounceRequest(localIP) - resp, err := c.sendRequest(ctx, req) - if err != nil { - return 0, fmt.Errorf("send announce: %w", err) - } - - parsed, err := parseResponse(resp) - if err != nil { - return 0, fmt.Errorf("parse announce response: %w", err) - } - - if parsed.ResultCode != ResultSuccess { - return 0, fmt.Errorf("PCP ANNOUNCE failed: %s", ResultCodeString(parsed.ResultCode)) - } - - c.mu.Lock() - if c.updateEpochLocked(parsed.Epoch) { - log.Warnf("PCP server epoch indicates state loss - mappings may need refresh") - } - c.mu.Unlock() - return parsed.Epoch, nil -} - -// AddPortMapping requests a port mapping from the PCP server. -func (c *Client) AddPortMapping(ctx context.Context, protocol string, internalPort int, lifetime time.Duration) (*MapResponse, error) { - return c.addPortMappingWithHint(ctx, protocol, internalPort, internalPort, netip.Addr{}, lifetime) -} - -// AddPortMappingWithHint requests a port mapping with suggested external port and IP. -// Use lifetime <= 0 to delete a mapping. -func (c *Client) AddPortMappingWithHint(ctx context.Context, protocol string, internalPort, suggestedExtPort int, suggestedExtIP net.IP, lifetime time.Duration) (*MapResponse, error) { - var extIP netip.Addr - if suggestedExtIP != nil { - var ok bool - extIP, ok = netip.AddrFromSlice(suggestedExtIP) - if !ok { - log.Debugf("invalid suggested external IP: %v", suggestedExtIP) - } - extIP = extIP.Unmap() - } - return c.addPortMappingWithHint(ctx, protocol, internalPort, suggestedExtPort, extIP, lifetime) -} - -func (c *Client) addPortMappingWithHint(ctx context.Context, protocol string, internalPort, suggestedExtPort int, suggestedExtIP netip.Addr, lifetime time.Duration) (*MapResponse, error) { - localIP, err := c.getLocalIP() - if err != nil { - return nil, fmt.Errorf("get local IP: %w", err) - } - - proto, err := protocolNumber(protocol) - if err != nil { - return nil, fmt.Errorf("parse protocol: %w", err) - } - - var nonce [12]byte - if _, err := rand.Read(nonce[:]); err != nil { - return nil, fmt.Errorf("generate nonce: %w", err) - } - - // Convert lifetime to seconds. Lifetime 0 means delete, so only apply - // default for positive durations that round to 0 seconds. - var lifetimeSec uint32 - if lifetime > 0 { - lifetimeSec = uint32(lifetime.Seconds()) - if lifetimeSec == 0 { - lifetimeSec = DefaultLifetime - } - } - - req := buildMapRequest(localIP, nonce, proto, uint16(internalPort), uint16(suggestedExtPort), suggestedExtIP, lifetimeSec) - - resp, err := c.sendRequest(ctx, req) - if err != nil { - return nil, fmt.Errorf("send map request: %w", err) - } - - mapResp, err := parseMapResponse(resp) - if err != nil { - return nil, fmt.Errorf("parse map response: %w", err) - } - - if mapResp.Nonce != nonce { - return nil, fmt.Errorf("nonce mismatch in response") - } - - if mapResp.Protocol != proto { - return nil, fmt.Errorf("protocol mismatch: requested %d, got %d", proto, mapResp.Protocol) - } - if mapResp.InternalPort != uint16(internalPort) { - return nil, fmt.Errorf("internal port mismatch: requested %d, got %d", internalPort, mapResp.InternalPort) - } - - if mapResp.ResultCode != ResultSuccess { - return nil, &Error{ - Code: mapResp.ResultCode, - Message: ResultCodeString(mapResp.ResultCode), - } - } - - c.mu.Lock() - if c.updateEpochLocked(mapResp.Epoch) { - log.Warnf("PCP server epoch indicates state loss - mappings may need refresh") - } - c.cacheExternalIPLocked(mapResp.ExternalIP) - c.mu.Unlock() - return mapResp, nil -} - -// DeletePortMapping removes a port mapping by requesting zero lifetime. -func (c *Client) DeletePortMapping(ctx context.Context, protocol string, internalPort int) error { - if _, err := c.addPortMappingWithHint(ctx, protocol, internalPort, 0, netip.Addr{}, 0); err != nil { - var pcpErr *Error - if errors.As(err, &pcpErr) && pcpErr.Code == ResultNotAuthorized { - return nil - } - return fmt.Errorf("delete mapping: %w", err) - } - return nil -} - -// GetExternalAddress returns the external IP address. -// First checks for a cached value from previous MAP responses. -// If not cached, creates a short-lived mapping to discover the external IP. -func (c *Client) GetExternalAddress(ctx context.Context) (net.IP, error) { - c.mu.Lock() - if c.externalIP.IsValid() { - ip := c.externalIP.AsSlice() - c.mu.Unlock() - return ip, nil - } - c.mu.Unlock() - - // Use an ephemeral port in the dynamic range (49152-65535). - // Port 0 is not valid with UDP/TCP protocols per RFC 6887. - ephemeralPort := 49152 + int(uint16(time.Now().UnixNano()))%(65535-49152) - - // Use minimal lifetime (1 second) for discovery. - resp, err := c.AddPortMapping(ctx, "udp", ephemeralPort, time.Second) - if err != nil { - return nil, fmt.Errorf("create temporary mapping: %w", err) - } - - if err := c.DeletePortMapping(ctx, "udp", ephemeralPort); err != nil { - log.Debugf("cleanup temporary PCP mapping: %v", err) - } - - return resp.ExternalIP.AsSlice(), nil -} - -// LastEpoch returns the last observed server epoch value. -// A decrease in epoch indicates the server may have restarted and mappings may be lost. -func (c *Client) LastEpoch() uint32 { - c.mu.Lock() - defer c.mu.Unlock() - return c.lastEpoch -} - -// EpochStateLost returns true if epoch state loss was detected and clears the flag. -func (c *Client) EpochStateLost() bool { - c.mu.Lock() - defer c.mu.Unlock() - lost := c.epochStateLost - c.epochStateLost = false - return lost -} - -// updateEpoch updates the epoch tracking and detects potential state loss. -// Returns true if state loss was detected (server likely restarted). -// Caller must hold c.mu. -func (c *Client) updateEpochLocked(newEpoch uint32) bool { - now := time.Now() - stateLost := false - - // RFC 6887 Section 8.5: Detect invalid epoch indicating server state loss. - // client_delta = time since last response - // server_delta = epoch change since last response - // Invalid if: client_delta+2 < server_delta - server_delta/16 - // OR: server_delta+2 < client_delta - client_delta/16 - // The +2 handles quantization, /16 (6.25%) handles clock drift. - if !c.epochTime.IsZero() && c.lastEpoch > 0 { - clientDelta := uint32(now.Sub(c.epochTime).Seconds()) - serverDelta := newEpoch - c.lastEpoch - - // Check for epoch going backwards or jumping unexpectedly. - // Subtraction is safe: serverDelta/16 is always <= serverDelta. - if clientDelta+2 < serverDelta-(serverDelta/16) || - serverDelta+2 < clientDelta-(clientDelta/16) { - stateLost = true - c.epochStateLost = true - } - } - - c.lastEpoch = newEpoch - c.epochTime = now - return stateLost -} - -// cacheExternalIP stores the external IP from a successful MAP response. -// Caller must hold c.mu. -func (c *Client) cacheExternalIPLocked(ip netip.Addr) { - if ip.IsValid() && !ip.IsUnspecified() { - c.externalIP = ip - } -} - -// sendRequest sends a PCP request with retries per RFC 6887 Section 8.1.1. -func (c *Client) sendRequest(ctx context.Context, req []byte) ([]byte, error) { - addr := &net.UDPAddr{IP: c.gateway.AsSlice(), Port: Port} - - var lastErr error - delay := initialRetryDelay - - for range maxRetries { - resp, err := c.sendOnce(ctx, addr, req) - if err == nil { - return resp, nil - } - lastErr = err - - if ctx.Err() != nil { - return nil, ctx.Err() - } - - // RFC 6887 Section 8.1.1: RT = (1 + RAND) * MIN(2 * RTprev, MRT) - // RAND is random between -0.1 and +0.1 - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(retryDelayWithJitter(delay)): - } - delay = min(delay*2, maxRetryDelay) - } - - return nil, fmt.Errorf("PCP request failed after %d retries: %w", maxRetries, lastErr) -} - -// retryDelayWithJitter applies RFC 6887 jitter: multiply by (1 + RAND) where RAND is [-0.1, +0.1]. -func retryDelayWithJitter(d time.Duration) time.Duration { - var b [1]byte - _, _ = rand.Read(b[:]) - // Convert byte to range [-0.1, +0.1]: (b/255 * 0.2) - 0.1 - jitter := (float64(b[0])/255.0)*0.2 - 0.1 - return time.Duration(float64(d) * (1 + jitter)) -} - -func (c *Client) sendOnce(ctx context.Context, addr *net.UDPAddr, req []byte) ([]byte, error) { - // Use ListenUDP instead of DialUDP to validate response source address per RFC 6887 §8.3. - conn, err := net.ListenUDP("udp", nil) - if err != nil { - return nil, fmt.Errorf("listen: %w", err) - } - defer func() { - if err := conn.Close(); err != nil { - log.Debugf("close UDP connection: %v", err) - } - }() - - timeout := c.timeout - if deadline, ok := ctx.Deadline(); ok { - if remaining := time.Until(deadline); remaining < timeout { - timeout = remaining - } - } - - if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil { - return nil, fmt.Errorf("set deadline: %w", err) - } - - if _, err := conn.WriteToUDP(req, addr); err != nil { - return nil, fmt.Errorf("write: %w", err) - } - - resp := make([]byte, responseBufferSize) - n, from, err := conn.ReadFromUDP(resp) - if err != nil { - return nil, fmt.Errorf("read: %w", err) - } - - // RFC 6887 §8.3: Validate response came from expected PCP server. - if !from.IP.Equal(addr.IP) { - return nil, fmt.Errorf("response from unexpected source %s (expected %s)", from.IP, addr.IP) - } - - return resp[:n], nil -} - -func (c *Client) getLocalIP() (netip.Addr, error) { - c.mu.Lock() - defer c.mu.Unlock() - - if !c.localIP.IsValid() { - return netip.Addr{}, fmt.Errorf("local IP not set for gateway %s", c.gateway) - } - return c.localIP, nil -} - -func protocolNumber(protocol string) (uint8, error) { - switch protocol { - case "udp", "UDP": - return ProtoUDP, nil - case "tcp", "TCP": - return ProtoTCP, nil - default: - return 0, fmt.Errorf("unsupported protocol: %s", protocol) - } -} - -// Error represents a PCP error response. -type Error struct { - Code uint8 - Message string -} - -func (e *Error) Error() string { - return fmt.Sprintf("PCP error: %s (%d)", e.Message, e.Code) -} diff --git a/client/internal/portforward/pcp/client_test.go b/client/internal/portforward/pcp/client_test.go deleted file mode 100644 index 79f44a426..000000000 --- a/client/internal/portforward/pcp/client_test.go +++ /dev/null @@ -1,187 +0,0 @@ -package pcp - -import ( - "context" - "net" - "net/netip" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestAddrConversion(t *testing.T) { - tests := []struct { - name string - addr netip.Addr - }{ - {"IPv4", netip.MustParseAddr("192.168.1.100")}, - {"IPv4 loopback", netip.MustParseAddr("127.0.0.1")}, - {"IPv6", netip.MustParseAddr("2001:db8::1")}, - {"IPv6 loopback", netip.MustParseAddr("::1")}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - b16 := addrTo16(tt.addr) - - recovered := addrFrom16(b16) - assert.Equal(t, tt.addr, recovered, "address should round-trip") - }) - } -} - -func TestBuildAnnounceRequest(t *testing.T) { - clientIP := netip.MustParseAddr("192.168.1.100") - req := buildAnnounceRequest(clientIP) - - require.Len(t, req, headerSize) - assert.Equal(t, byte(Version), req[0], "version") - assert.Equal(t, byte(OpAnnounce), req[1], "opcode") - - // Check client IP is properly encoded as IPv4-mapped IPv6 - assert.Equal(t, byte(0xff), req[18], "IPv4-mapped prefix byte 10") - assert.Equal(t, byte(0xff), req[19], "IPv4-mapped prefix byte 11") - assert.Equal(t, byte(192), req[20], "IP octet 1") - assert.Equal(t, byte(168), req[21], "IP octet 2") - assert.Equal(t, byte(1), req[22], "IP octet 3") - assert.Equal(t, byte(100), req[23], "IP octet 4") -} - -func TestBuildMapRequest(t *testing.T) { - clientIP := netip.MustParseAddr("192.168.1.100") - nonce := [12]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12} - req := buildMapRequest(clientIP, nonce, ProtoUDP, 51820, 51820, netip.Addr{}, 3600) - - require.Len(t, req, mapRequestSize) - assert.Equal(t, byte(Version), req[0], "version") - assert.Equal(t, byte(OpMap), req[1], "opcode") - - // Lifetime at bytes 4-7 - assert.Equal(t, uint32(3600), (uint32(req[4])<<24)|(uint32(req[5])<<16)|(uint32(req[6])<<8)|uint32(req[7]), "lifetime") - - // Nonce at bytes 24-35 - assert.Equal(t, nonce[:], req[24:36], "nonce") - - // Protocol at byte 36 - assert.Equal(t, byte(ProtoUDP), req[36], "protocol") - - // Internal port at bytes 40-41 - assert.Equal(t, uint16(51820), (uint16(req[40])<<8)|uint16(req[41]), "internal port") - - // External port at bytes 42-43 - assert.Equal(t, uint16(51820), (uint16(req[42])<<8)|uint16(req[43]), "external port") -} - -func TestParseResponse(t *testing.T) { - // Construct a valid ANNOUNCE response - resp := make([]byte, headerSize) - resp[0] = Version - resp[1] = OpAnnounce | OpReply - // Result code = 0 (success) - // Lifetime = 0 - // Epoch = 12345 - resp[8] = 0 - resp[9] = 0 - resp[10] = 0x30 - resp[11] = 0x39 - - parsed, err := parseResponse(resp) - require.NoError(t, err) - assert.Equal(t, uint8(Version), parsed.Version) - assert.Equal(t, uint8(OpAnnounce|OpReply), parsed.Opcode) - assert.Equal(t, uint8(ResultSuccess), parsed.ResultCode) - assert.Equal(t, uint32(12345), parsed.Epoch) -} - -func TestParseResponseErrors(t *testing.T) { - t.Run("too short", func(t *testing.T) { - _, err := parseResponse([]byte{1, 2, 3}) - assert.Error(t, err) - }) - - t.Run("wrong version", func(t *testing.T) { - resp := make([]byte, headerSize) - resp[0] = 1 // Wrong version - resp[1] = OpReply - _, err := parseResponse(resp) - assert.Error(t, err) - }) - - t.Run("missing reply bit", func(t *testing.T) { - resp := make([]byte, headerSize) - resp[0] = Version - resp[1] = OpAnnounce // Missing OpReply bit - _, err := parseResponse(resp) - assert.Error(t, err) - }) -} - -func TestResultCodeString(t *testing.T) { - assert.Equal(t, "SUCCESS", ResultCodeString(ResultSuccess)) - assert.Equal(t, "NOT_AUTHORIZED", ResultCodeString(ResultNotAuthorized)) - assert.Equal(t, "ADDRESS_MISMATCH", ResultCodeString(ResultAddressMismatch)) - assert.Contains(t, ResultCodeString(255), "UNKNOWN") -} - -func TestProtocolNumber(t *testing.T) { - proto, err := protocolNumber("udp") - require.NoError(t, err) - assert.Equal(t, uint8(ProtoUDP), proto) - - proto, err = protocolNumber("tcp") - require.NoError(t, err) - assert.Equal(t, uint8(ProtoTCP), proto) - - proto, err = protocolNumber("UDP") - require.NoError(t, err) - assert.Equal(t, uint8(ProtoUDP), proto) - - _, err = protocolNumber("icmp") - assert.Error(t, err) -} - -func TestClientCreation(t *testing.T) { - gateway := netip.MustParseAddr("192.168.1.1").AsSlice() - - client := NewClient(gateway) - assert.Equal(t, net.IP(gateway), client.Gateway()) - assert.Equal(t, defaultTimeout, client.timeout) - - clientWithTimeout := NewClientWithTimeout(gateway, 5*time.Second) - assert.Equal(t, 5*time.Second, clientWithTimeout.timeout) -} - -func TestNATType(t *testing.T) { - n := NewNAT(netip.MustParseAddr("192.168.1.1").AsSlice(), netip.MustParseAddr("192.168.1.100").AsSlice()) - assert.Equal(t, "PCP", n.Type()) -} - -// Integration test - skipped unless PCP_TEST_GATEWAY env is set -func TestClientIntegration(t *testing.T) { - t.Skip("Integration test - run manually with PCP_TEST_GATEWAY=") - - gateway := netip.MustParseAddr("10.0.1.1").AsSlice() // Change to your test gateway - localIP := netip.MustParseAddr("10.0.1.100").AsSlice() // Change to your local IP - - client := NewClient(gateway) - client.SetLocalIP(localIP) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - // Test ANNOUNCE - epoch, err := client.Announce(ctx) - require.NoError(t, err) - t.Logf("Server epoch: %d", epoch) - - // Test MAP - resp, err := client.AddPortMapping(ctx, "udp", 51820, 1*time.Hour) - require.NoError(t, err) - t.Logf("Mapping: internal=%d external=%d externalIP=%s", - resp.InternalPort, resp.ExternalPort, resp.ExternalIP) - - // Cleanup - err = client.DeletePortMapping(ctx, "udp", 51820) - require.NoError(t, err) -} diff --git a/client/internal/portforward/pcp/nat.go b/client/internal/portforward/pcp/nat.go deleted file mode 100644 index 0e635b6c8..000000000 --- a/client/internal/portforward/pcp/nat.go +++ /dev/null @@ -1,222 +0,0 @@ -package pcp - -import ( - "context" - "fmt" - "net" - "net/netip" - "runtime" - "sync" - "time" - - log "github.com/sirupsen/logrus" - - "github.com/libp2p/go-nat" - "github.com/libp2p/go-netroute" -) - -var _ nat.NAT = (*NAT)(nil) - -// NAT implements the go-nat NAT interface using PCP. -// Supports dual-stack (IPv4 and IPv6) when available. -// All methods are safe for concurrent use. -// -// TODO: IPv6 pinholes use the local IPv6 address. If the address changes -// (e.g., due to SLAAC rotation or network change), the pinhole becomes stale -// and needs to be recreated with the new address. -type NAT struct { - client *Client - - mu sync.RWMutex - // client6 is the IPv6 PCP client, nil if IPv6 is unavailable. - client6 *Client - // localIP6 caches the local IPv6 address used for PCP requests. - localIP6 netip.Addr -} - -// NewNAT creates a new NAT instance backed by PCP. -func NewNAT(gateway, localIP net.IP) *NAT { - client := NewClient(gateway) - client.SetLocalIP(localIP) - return &NAT{ - client: client, - } -} - -// Type returns "PCP" as the NAT type. -func (n *NAT) Type() string { - return "PCP" -} - -// GetDeviceAddress returns the gateway IP address. -func (n *NAT) GetDeviceAddress() (net.IP, error) { - return n.client.Gateway(), nil -} - -// GetExternalAddress returns the external IP address. -func (n *NAT) GetExternalAddress() (net.IP, error) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - return n.client.GetExternalAddress(ctx) -} - -// GetInternalAddress returns the local IP address used to communicate with the gateway. -func (n *NAT) GetInternalAddress() (net.IP, error) { - addr, err := n.client.getLocalIP() - if err != nil { - return nil, err - } - return addr.AsSlice(), nil -} - -// AddPortMapping creates a port mapping on both IPv4 and IPv6 (if available). -func (n *NAT) AddPortMapping(ctx context.Context, protocol string, internalPort int, _ string, timeout time.Duration) (int, error) { - resp, err := n.client.AddPortMapping(ctx, protocol, internalPort, timeout) - if err != nil { - return 0, fmt.Errorf("add mapping: %w", err) - } - - n.mu.RLock() - client6 := n.client6 - localIP6 := n.localIP6 - n.mu.RUnlock() - - if client6 == nil { - return int(resp.ExternalPort), nil - } - - if _, err := client6.AddPortMapping(ctx, protocol, internalPort, timeout); err != nil { - log.Warnf("IPv6 PCP mapping failed (continuing with IPv4): %v", err) - return int(resp.ExternalPort), nil - } - - log.Infof("created IPv6 PCP pinhole: %s:%d", localIP6, internalPort) - return int(resp.ExternalPort), nil -} - -// DeletePortMapping removes a port mapping from both IPv4 and IPv6. -func (n *NAT) DeletePortMapping(ctx context.Context, protocol string, internalPort int) error { - err := n.client.DeletePortMapping(ctx, protocol, internalPort) - - n.mu.RLock() - client6 := n.client6 - n.mu.RUnlock() - - if client6 != nil { - if err6 := client6.DeletePortMapping(ctx, protocol, internalPort); err6 != nil { - log.Warnf("IPv6 PCP delete mapping failed: %v", err6) - } - } - - if err != nil { - return fmt.Errorf("delete mapping: %w", err) - } - return nil -} - -// CheckServerHealth sends an ANNOUNCE to verify the server is still responsive. -// Returns the current epoch and whether the server may have restarted (epoch state loss detected). -func (n *NAT) CheckServerHealth(ctx context.Context) (epoch uint32, serverRestarted bool, err error) { - epoch, err = n.client.Announce(ctx) - if err != nil { - return 0, false, fmt.Errorf("announce: %w", err) - } - return epoch, n.client.EpochStateLost(), nil -} - -// DiscoverPCP attempts to discover a PCP-capable gateway. -// Returns a NAT interface if PCP is supported, or an error otherwise. -// Discovers both IPv4 and IPv6 gateways when available. -func DiscoverPCP(ctx context.Context) (nat.NAT, error) { - gateway, localIP, err := getDefaultGateway() - if err != nil { - return nil, fmt.Errorf("get default gateway: %w", err) - } - - client := NewClient(gateway) - client.SetLocalIP(localIP) - if _, err := client.Announce(ctx); err != nil { - return nil, fmt.Errorf("PCP announce: %w", err) - } - - result := &NAT{client: client} - discoverIPv6(ctx, result) - - return result, nil -} - -func discoverIPv6(ctx context.Context, result *NAT) { - gateway6, localIP6, err := getDefaultGateway6() - if err != nil { - log.Debugf("IPv6 gateway discovery failed: %v", err) - return - } - - client6 := NewClient(gateway6) - client6.SetLocalIP(localIP6) - if _, err := client6.Announce(ctx); err != nil { - log.Debugf("PCP IPv6 announce failed: %v", err) - return - } - - addr, ok := netip.AddrFromSlice(localIP6) - if !ok { - log.Debugf("invalid IPv6 local IP: %v", localIP6) - return - } - result.mu.Lock() - result.client6 = client6 - result.localIP6 = addr - result.mu.Unlock() - log.Debugf("PCP IPv6 gateway discovered: %s (local: %s)", gateway6, localIP6) -} - -// getDefaultGateway returns the default IPv4 gateway and local IP using the system routing table. -func getDefaultGateway() (gateway net.IP, localIP net.IP, err error) { - router, err := netroute.New() - if err != nil { - return nil, nil, err - } - - dst := net.IPv4zero - if runtime.GOOS == "linux" || runtime.GOOS == "android" { - // go-netroute v0.4.0 rejects unspecified destinations client-side on Linux/Android. - // TODO: on android/ios, use platform APIs (ConnectivityManager.getLinkProperties / - // NWPathMonitor) when netlink-based lookup is restricted or unavailable. - dst = net.IPv4(0, 0, 0, 1) - } - _, gateway, localIP, err = router.Route(dst) - if err != nil { - return nil, nil, err - } - - if gateway == nil { - return nil, nil, nat.ErrNoNATFound - } - - return gateway, localIP, nil -} - -// getDefaultGateway6 returns the default IPv6 gateway IP address using the system routing table. -func getDefaultGateway6() (gateway net.IP, localIP net.IP, err error) { - router, err := netroute.New() - if err != nil { - return nil, nil, err - } - - dst := net.IPv6zero - if runtime.GOOS == "linux" || runtime.GOOS == "android" { - // ::2 - dst = net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2} - } - _, gateway, localIP, err = router.Route(dst) - if err != nil { - return nil, nil, err - } - - if gateway == nil { - return nil, nil, nat.ErrNoNATFound - } - - return gateway, localIP, nil -} diff --git a/client/internal/portforward/pcp/protocol.go b/client/internal/portforward/pcp/protocol.go deleted file mode 100644 index d81c50c8c..000000000 --- a/client/internal/portforward/pcp/protocol.go +++ /dev/null @@ -1,225 +0,0 @@ -// Package pcp implements the Port Control Protocol (RFC 6887). -// -// # Implemented Features -// -// - ANNOUNCE opcode: Discovers PCP server support -// - MAP opcode: Creates/deletes port mappings (IPv4 NAT) and firewall pinholes (IPv6) -// - Dual-stack: Simultaneous IPv4 and IPv6 support via separate clients -// - Nonce validation: Prevents response spoofing -// - Epoch tracking: Detects server restarts per Section 8.5 -// - RFC-compliant retry timing: 3s initial, exponential backoff to 1024s max (Section 8.1.1) -// -// # Not Implemented -// -// - PEER opcode: For outbound peer connections (not needed for inbound NAT traversal) -// - THIRD_PARTY option: For managing mappings on behalf of other devices -// - PREFER_FAILURE option: Requires exact external port or fail (IPv4 NAT only, not needed for IPv6 pinholing) -// - FILTER option: To restrict remote peer addresses -// -// These optional features are omitted because the primary use case is simple -// port forwarding for WireGuard, which only requires MAP with default behavior. -package pcp - -import ( - "encoding/binary" - "fmt" - "net/netip" -) - -const ( - // Version is the PCP protocol version (RFC 6887). - Version = 2 - - // Port is the standard PCP server port. - Port = 5351 - - // DefaultLifetime is the default requested mapping lifetime in seconds. - DefaultLifetime = 7200 // 2 hours - - // Header sizes - headerSize = 24 - mapPayloadSize = 36 - mapRequestSize = headerSize + mapPayloadSize // 60 bytes -) - -// Opcodes -const ( - OpAnnounce = 0 - OpMap = 1 - OpPeer = 2 - OpReply = 0x80 // OR'd with opcode in responses -) - -// Protocol numbers for MAP requests -const ( - ProtoUDP = 17 - ProtoTCP = 6 -) - -// Result codes (RFC 6887 Section 7.4) -const ( - ResultSuccess = 0 - ResultUnsuppVersion = 1 - ResultNotAuthorized = 2 - ResultMalformedRequest = 3 - ResultUnsuppOpcode = 4 - ResultUnsuppOption = 5 - ResultMalformedOption = 6 - ResultNetworkFailure = 7 - ResultNoResources = 8 - ResultUnsuppProtocol = 9 - ResultUserExQuota = 10 - ResultCannotProvideExt = 11 - ResultAddressMismatch = 12 - ResultExcessiveRemotePeers = 13 -) - -// ResultCodeString returns a human-readable string for a result code. -func ResultCodeString(code uint8) string { - switch code { - case ResultSuccess: - return "SUCCESS" - case ResultUnsuppVersion: - return "UNSUPP_VERSION" - case ResultNotAuthorized: - return "NOT_AUTHORIZED" - case ResultMalformedRequest: - return "MALFORMED_REQUEST" - case ResultUnsuppOpcode: - return "UNSUPP_OPCODE" - case ResultUnsuppOption: - return "UNSUPP_OPTION" - case ResultMalformedOption: - return "MALFORMED_OPTION" - case ResultNetworkFailure: - return "NETWORK_FAILURE" - case ResultNoResources: - return "NO_RESOURCES" - case ResultUnsuppProtocol: - return "UNSUPP_PROTOCOL" - case ResultUserExQuota: - return "USER_EX_QUOTA" - case ResultCannotProvideExt: - return "CANNOT_PROVIDE_EXTERNAL" - case ResultAddressMismatch: - return "ADDRESS_MISMATCH" - case ResultExcessiveRemotePeers: - return "EXCESSIVE_REMOTE_PEERS" - default: - return fmt.Sprintf("UNKNOWN(%d)", code) - } -} - -// Response represents a parsed PCP response header. -type Response struct { - Version uint8 - Opcode uint8 - ResultCode uint8 - Lifetime uint32 - Epoch uint32 -} - -// MapResponse contains the full response to a MAP request. -type MapResponse struct { - Response - Nonce [12]byte - Protocol uint8 - InternalPort uint16 - ExternalPort uint16 - ExternalIP netip.Addr -} - -// addrTo16 converts an address to its 16-byte IPv4-mapped IPv6 representation. -func addrTo16(addr netip.Addr) [16]byte { - if addr.Is4() { - return netip.AddrFrom4(addr.As4()).As16() - } - return addr.As16() -} - -// addrFrom16 extracts an address from a 16-byte representation, unmapping IPv4. -func addrFrom16(b [16]byte) netip.Addr { - return netip.AddrFrom16(b).Unmap() -} - -// buildAnnounceRequest creates a PCP ANNOUNCE request packet. -func buildAnnounceRequest(clientIP netip.Addr) []byte { - req := make([]byte, headerSize) - req[0] = Version - req[1] = OpAnnounce - mapped := addrTo16(clientIP) - copy(req[8:24], mapped[:]) - return req -} - -// buildMapRequest creates a PCP MAP request packet. -func buildMapRequest(clientIP netip.Addr, nonce [12]byte, protocol uint8, internalPort, suggestedExtPort uint16, suggestedExtIP netip.Addr, lifetime uint32) []byte { - req := make([]byte, mapRequestSize) - - // Header - req[0] = Version - req[1] = OpMap - binary.BigEndian.PutUint32(req[4:8], lifetime) - mapped := addrTo16(clientIP) - copy(req[8:24], mapped[:]) - - // MAP payload - copy(req[24:36], nonce[:]) - req[36] = protocol - binary.BigEndian.PutUint16(req[40:42], internalPort) - binary.BigEndian.PutUint16(req[42:44], suggestedExtPort) - if suggestedExtIP.IsValid() { - extMapped := addrTo16(suggestedExtIP) - copy(req[44:60], extMapped[:]) - } - - return req -} - -// parseResponse parses the common PCP response header. -func parseResponse(data []byte) (*Response, error) { - if len(data) < headerSize { - return nil, fmt.Errorf("response too short: %d bytes", len(data)) - } - - resp := &Response{ - Version: data[0], - Opcode: data[1], - ResultCode: data[3], // Byte 2 is reserved, byte 3 is result code (RFC 6887 §7.2) - Lifetime: binary.BigEndian.Uint32(data[4:8]), - Epoch: binary.BigEndian.Uint32(data[8:12]), - } - - if resp.Version != Version { - return nil, fmt.Errorf("unsupported PCP version: %d", resp.Version) - } - - if resp.Opcode&OpReply == 0 { - return nil, fmt.Errorf("response missing reply bit: opcode=0x%02x", resp.Opcode) - } - - return resp, nil -} - -// parseMapResponse parses a complete MAP response. -func parseMapResponse(data []byte) (*MapResponse, error) { - if len(data) < mapRequestSize { - return nil, fmt.Errorf("MAP response too short: %d bytes", len(data)) - } - - resp, err := parseResponse(data) - if err != nil { - return nil, fmt.Errorf("parse header: %w", err) - } - - mapResp := &MapResponse{ - Response: *resp, - Protocol: data[36], - InternalPort: binary.BigEndian.Uint16(data[40:42]), - ExternalPort: binary.BigEndian.Uint16(data[42:44]), - ExternalIP: addrFrom16([16]byte(data[44:60])), - } - copy(mapResp.Nonce[:], data[24:36]) - - return mapResp, nil -} diff --git a/client/internal/portforward/pinhole_test.go b/client/internal/portforward/pinhole_test.go new file mode 100644 index 000000000..46b07a9e7 --- /dev/null +++ b/client/internal/portforward/pinhole_test.go @@ -0,0 +1,116 @@ +//go:build !js + +package portforward + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/netbirdio/go-nat" + log "github.com/sirupsen/logrus" + "github.com/sirupsen/logrus/hooks/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockPinholeNAT is a gateway that also reports an IPv6 pinhole outcome, the +// shape a dual-stack gateway has. +type mockPinholeNAT struct { + *mockNAT + pinholeErr error +} + +func (m *mockPinholeNAT) IPv6PinholeError() error { + return m.pinholeErr +} + +func TestSetupLogsPinholeOutcome(t *testing.T) { + pinholeErr := errors.New("pcp ipv6: NOT_AUTHORIZED") + + tests := []struct { + name string + pinholeErr error + mappingErr error + wantLevel log.Level + wantText string + }{ + { + name: "an open pinhole is reported", + wantLevel: log.InfoLevel, + wantText: "IPv6 pinhole open", + }, + { + name: "a failed pinhole is reported without failing the mapping", + // The IPv4 mapping is what the caller asked for, so the pinhole + // failure surfaces only in the log. + pinholeErr: pinholeErr, + wantLevel: log.WarnLevel, + wantText: pinholeErr.Error(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gateway := &mockPinholeNAT{mockNAT: newMockNAT(), pinholeErr: tt.pinholeErr} + hook := stubGatewayDiscovery(t, gateway) + + m := NewManager() + m.wgPort = 51820 + + _, mapping, err := m.setup(context.Background()) + + require.NoError(t, err) + require.NotNil(t, mapping) + + entry := findEntry(hook, tt.wantText) + require.NotNil(t, entry, "no log entry mentioning %q", tt.wantText) + assert.Equal(t, tt.wantLevel, entry.Level) + }) + } + + t.Run("a failed mapping reports no pinhole outcome", func(t *testing.T) { + // Nothing opened the pinhole, so whatever it currently reports says + // nothing about this attempt. + gateway := &mockPinholeNAT{mockNAT: newMockNAT()} + gateway.addMappingErr = errors.New("gateway refused") + hook := stubGatewayDiscovery(t, gateway) + + m := NewManager() + m.wgPort = 51820 + + _, _, err := m.setup(context.Background()) + + require.Error(t, err) + assert.Nil(t, findEntry(hook, "IPv6 pinhole")) + }) +} + +// stubGatewayDiscovery makes discovery return gateway and captures log output. +func stubGatewayDiscovery(t *testing.T, gateway nat.NAT) *test.Hook { + t.Helper() + + orig := discoverGateway + discoverGateway = func(context.Context) (nat.NAT, error) { return gateway, nil } + t.Cleanup(func() { discoverGateway = orig }) + + hook := test.NewGlobal() + origLevel := log.GetLevel() + log.SetLevel(log.DebugLevel) + t.Cleanup(func() { + hook.Reset() + log.SetLevel(origLevel) + }) + + return hook +} + +func findEntry(hook *test.Hook, substr string) *log.Entry { + for _, entry := range hook.AllEntries() { + if strings.Contains(entry.Message, substr) { + return entry + } + } + return nil +} diff --git a/client/internal/portforward/state.go b/client/internal/portforward/state.go index b1315cdc0..a21368e58 100644 --- a/client/internal/portforward/state.go +++ b/client/internal/portforward/state.go @@ -4,27 +4,94 @@ package portforward import ( "context" + "errors" "fmt" + "time" - "github.com/libp2p/go-nat" + "github.com/netbirdio/go-nat" + "github.com/netbirdio/go-nat/pcp" log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/internal/portforward/pcp" ) // discoverGateway is the function used for NAT gateway discovery. // It can be replaced in tests to avoid real network operations. -// Tries PCP first, then falls back to NAT-PMP/UPnP. var discoverGateway = defaultDiscoverGateway -func defaultDiscoverGateway(ctx context.Context) (nat.NAT, error) { - pcpGateway, err := pcp.DiscoverPCP(ctx) - if err == nil { - return pcpGateway, nil - } - log.Debugf("PCP discovery failed: %v, trying NAT-PMP/UPnP", err) +// pinholeDiscoveryTimeout is the slice of the discovery budget held back for +// the IPv6 pinhole probe. +// +// Sizing it is coarser than it looks: PCP retransmits on a 3s socket timeout +// and a 3s first backoff, so a second attempt needs about 9s. Anything from +// roughly 1s to 8s therefore buys exactly one attempt, and this only sets how +// long that attempt waits. A PCP server sits on the local link and answers in +// milliseconds, so 3s is margin rather than need, and the rest is left to +// gateway discovery, whose multicast SSDP search alone takes 5s. A probe lost +// to a dropped packet is retried by the next discovery round. +// +// It is a variable so tests can shorten it. +var pinholeDiscoveryTimeout = 3 * time.Second - return nat.DiscoverGateway(ctx) +// Discovery entry points, as variables so tests can drive the fallback without +// touching the network. +var ( + discoverNATGateway = nat.DiscoverGateway + + discoverPCPPinhole = func(ctx context.Context) (nat.NAT, error) { + pinhole, err := pcp.DiscoverPCP(ctx) + if err != nil { + return nil, err + } + return pinhole, nil + } +) + +// defaultDiscoverGateway finds a gateway that can make the WireGuard port +// reachable. DiscoverGateway prefers PCP for IPv4, races UPnP and NAT-PMP +// behind it, and attaches an IPv6 pinhole independently of which IPv4 protocol +// wins. +// +// It reports no gateway on a network offering only IPv6, having no IPv4 mapping +// to attach a pinhole to. Such a network still needs one: there is no +// translation to traverse, but the router drops inbound IPv6 until something +// opens it. Fall back to PCP alone, which yields a gateway holding just the +// pinhole. +func defaultDiscoverGateway(ctx context.Context) (nat.NAT, error) { + gatewayCtx, cancel := reserveForPinhole(ctx) + defer cancel() + + gateway, err := discoverNATGateway(gatewayCtx) + if err == nil { + return gateway, nil + } + if !errors.Is(err, nat.ErrNoNATFound) { + return nil, err + } + + pinhole, pinholeErr := discoverPCPPinhole(ctx) + if pinholeErr != nil { + log.Debugf("no IPv6 pinhole after %v: %v", err, pinholeErr) + return nil, err + } + + log.Infof("no IPv4 gateway, continuing with an IPv6 pinhole only") + return pinhole, nil +} + +// reserveForPinhole shortens ctx so that a pinhole probe still has time to run +// afterwards. Finding nothing takes gateway discovery everything it is given, +// so on the unshortened context the probe would start already expired. A budget +// too small to divide is left to gateway discovery, which is the likelier win. +func reserveForPinhole(ctx context.Context) (context.Context, context.CancelFunc) { + deadline, ok := ctx.Deadline() + if !ok { + return context.WithCancel(ctx) + } + + remaining := time.Until(deadline) + if remaining <= pinholeDiscoveryTimeout { + return context.WithCancel(ctx) + } + return context.WithTimeout(ctx, remaining-pinholeDiscoveryTimeout) } // State is persisted only for crash recovery cleanup diff --git a/client/internal/portforward/state_test.go b/client/internal/portforward/state_test.go new file mode 100644 index 000000000..8a584eecb --- /dev/null +++ b/client/internal/portforward/state_test.go @@ -0,0 +1,140 @@ +//go:build !js + +package portforward + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/netbirdio/go-nat" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// stubDiscovery replaces both discovery entry points for the duration of a +// test. gatewayDelay simulates gateway discovery spending everything it is +// given before reporting that it found nothing. +func stubDiscovery(t *testing.T, gateway nat.NAT, gatewayErr error, gatewayDelay time.Duration, pinhole nat.NAT, pinholeErr error) { + t.Helper() + + origGateway, origPinhole := discoverNATGateway, discoverPCPPinhole + discoverNATGateway = func(ctx context.Context) (nat.NAT, error) { + if gatewayDelay > 0 { + select { + case <-time.After(gatewayDelay): + case <-ctx.Done(): + } + } + return gateway, gatewayErr + } + discoverPCPPinhole = func(ctx context.Context) (nat.NAT, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + return pinhole, pinholeErr + } + + t.Cleanup(func() { discoverNATGateway, discoverPCPPinhole = origGateway, origPinhole }) +} + +func TestDefaultDiscoverGateway(t *testing.T) { + ipv4Gateway := &mockNAT{natType: "PCP+PCPv6"} + ipv6Pinhole := &mockNAT{natType: "PCP"} + otherErr := errors.New("routing table unavailable") + + t.Run("an IPv4 gateway is used as is", func(t *testing.T) { + stubDiscovery(t, ipv4Gateway, nil, 0, ipv6Pinhole, nil) + + got, err := defaultDiscoverGateway(context.Background()) + + require.NoError(t, err) + assert.Same(t, ipv4Gateway, got) + }) + + t.Run("no IPv4 gateway still opens an IPv6 pinhole", func(t *testing.T) { + stubDiscovery(t, nil, nat.ErrNoNATFound, 0, ipv6Pinhole, nil) + + got, err := defaultDiscoverGateway(context.Background()) + + require.NoError(t, err) + assert.Same(t, ipv6Pinhole, got) + }) + + t.Run("no gateway and no pinhole reports the original failure", func(t *testing.T) { + stubDiscovery(t, nil, nat.ErrNoNATFound, 0, nil, errors.New("no IPv6 route")) + + got, err := defaultDiscoverGateway(context.Background()) + + assert.Nil(t, got) + assert.ErrorIs(t, err, nat.ErrNoNATFound, "the pinhole failure must not mask why no gateway was found") + }) + + t.Run("a failure other than no-gateway is reported as is", func(t *testing.T) { + stubDiscovery(t, nil, otherErr, 0, ipv6Pinhole, nil) + + got, err := defaultDiscoverGateway(context.Background()) + + assert.Nil(t, got) + assert.ErrorIs(t, err, otherErr) + }) + + t.Run("the pinhole survives gateway discovery using its whole budget", func(t *testing.T) { + // On one shared context the probe would start already expired, which is + // how this failed against a real gateway. + reserve := 50 * time.Millisecond + origReserve := pinholeDiscoveryTimeout + pinholeDiscoveryTimeout = reserve + t.Cleanup(func() { pinholeDiscoveryTimeout = origReserve }) + + budget := 4 * reserve + ctx, cancel := context.WithTimeout(context.Background(), budget) + defer cancel() + + stubDiscovery(t, nil, nat.ErrNoNATFound, budget, ipv6Pinhole, nil) + + got, err := defaultDiscoverGateway(ctx) + + require.NoError(t, err) + assert.Same(t, ipv6Pinhole, got) + }) +} + +func TestReserveForPinhole(t *testing.T) { + origReserve := pinholeDiscoveryTimeout + pinholeDiscoveryTimeout = time.Second + t.Cleanup(func() { pinholeDiscoveryTimeout = origReserve }) + + t.Run("a budget is divided", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + gatewayCtx, cancelGateway := reserveForPinhole(ctx) + defer cancelGateway() + + deadline, ok := gatewayCtx.Deadline() + require.True(t, ok) + assert.InDelta(t, 9*time.Second, time.Until(deadline), float64(500*time.Millisecond)) + }) + + t.Run("a budget too small to divide is left whole", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + gatewayCtx, cancelGateway := reserveForPinhole(ctx) + defer cancelGateway() + + deadline, ok := gatewayCtx.Deadline() + require.True(t, ok) + assert.InDelta(t, 500*time.Millisecond, time.Until(deadline), float64(100*time.Millisecond)) + }) + + t.Run("no deadline stays unbounded", func(t *testing.T) { + gatewayCtx, cancelGateway := reserveForPinhole(context.Background()) + defer cancelGateway() + + _, ok := gatewayCtx.Deadline() + assert.False(t, ok) + }) +} diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index ed2f21999..412f81b5c 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -58,10 +58,6 @@ var DefaultInterfaceBlacklist = []string{ "Tailscale", "tailscale", "docker", "veth", "br-", "lo", } -// loadMDMPolicy is the package-level indirection used by apply() to read the -// active MDM policy. Tests override this to inject a fake policy. -var loadMDMPolicy = mdm.LoadPolicy - // ConfigInput carries configuration changes to the client type ConfigInput struct { ManagementURL string @@ -70,6 +66,7 @@ type ConfigInput struct { StateFilePath string PreSharedKey *string ServerSSHAllowed *bool + RemoteJobsAllowed *bool EnableSSHRoot *bool EnableSSHSFTP *bool EnableSSHLocalPortForwarding *bool @@ -96,12 +93,16 @@ type ConfigInput struct { BlockLANAccess *bool BlockInbound *bool DisableIPv6 *bool + SyncMessageVersion *int DisableNotifications *bool DNSLabels domain.List MTU *uint16 + + LocalMetricsEnabled *bool + LocalMetricsAddress *string } // Config Configuration type @@ -123,6 +124,7 @@ type Config struct { RosenpassEnabled bool RosenpassPermissive bool ServerSSHAllowed *bool + RemoteJobsAllowed *bool EnableSSHRoot *bool EnableSSHSFTP *bool EnableSSHLocalPortForwarding *bool @@ -137,11 +139,17 @@ type Config struct { BlockLANAccess bool BlockInbound bool DisableIPv6 bool + SyncMessageVersion *int DisableNotifications *bool DNSLabels domain.List + // LocalMetricsEnabled enables the local Prometheus /metrics endpoint. + LocalMetricsEnabled bool + // LocalMetricsAddress is the listen address of the local /metrics endpoint. + LocalMetricsAddress string + // SSHKey is a private SSH key in a PEM format SSHKey string @@ -182,16 +190,34 @@ type Config struct { // Runtime-only: re-derived from MDM policy on each load, never persisted. LazyConnection string `json:"-"` + // DebugBundleUploadURL is the MDM-managed debug-bundle upload URL override. + // When set, it takes precedence over the management-supplied upload URL for + // remote debug bundle jobs. Runtime-only: re-derived from MDM policy on each + // load, never persisted. + DebugBundleUploadURL string `json:"-"` + MTU uint16 - // policy is the MDM policy that produced the currently-set values for - // any MDM-enforced fields. Set by applyMDMPolicy at the tail of apply() - // and reset on every apply() invocation. Never persisted to disk. - // Callers query enforcement state via Policy() and the mdm.Policy API - // (HasKey, ManagedKeys, IsEmpty). + // policy is the MDM policy that produced the currently-set values + // for any MDM-enforced fields. Set by ApplyMDMPolicy on every + // invocation. Never persisted to disk. Callers query enforcement + // state via Policy() and the mdm.Policy API (HasKey, ManagedKeys, + // IsEmpty). policy *mdm.Policy `json:"-"` } +// ApplyMDMPolicy overlays the supplied MDM Policy on top of the current +// Config values and records it as Policy(). The overlay is not reversible: +// an empty Policy only clears the enforcement metadata, so resolve the base +// Config again (from disk or JSON) before applying a changed policy, the way +// the lifecycle owners do on every load. +func (config *Config) ApplyMDMPolicy(policy *mdm.Policy) { + if config == nil { + return + } + config.applyMDMPolicy(policy) +} + // Policy returns the MDM policy applied to this Config. Returns a non-nil // empty Policy when MDM enforcement is inactive; callers can always invoke // HasKey / ManagedKeys / IsEmpty without a nil check. @@ -215,6 +241,12 @@ func getConfigDir() (string, error) { } configDir := filepath.Join(base, "netbird") + // Under sudo this is the invoking user's directory and strictly read-only: + // anything root creates in it would be root-owned and break the user's own + // runs. Reads of a missing directory fall through to defaults. + if sudoActive() { + return configDir, nil + } if err := os.MkdirAll(configDir, 0o755); err != nil { return "", err } @@ -222,6 +254,16 @@ func getConfigDir() (string, error) { } func baseConfigDir() (string, error) { + if u, ok := sudoInvokingUser(); ok { + return userBaseConfigDir(u) + } + // Fail closed instead of falling through to root's own config directory: + // reading root's active-profile and email state for what is actually the + // invoking user's invocation is the very confusion this resolution exists + // to prevent. + if sudoActive() { + return "", fmt.Errorf("resolve sudo invoking user %q: refusing to fall back to root's config directory", os.Getenv(envSudoUser)) + } if runtime.GOOS == "darwin" { if u, err := user.Current(); err == nil && u.HomeDir != "" { return filepath.Join(u.HomeDir, "Library", "Application Support"), nil @@ -263,7 +305,10 @@ func createNewConfig(input ConfigInput) (*Config, error) { config := &Config{ // defaults to false only for new (post 0.26) configurations ServerSSHAllowed: util.False(), - WgPort: iface.DefaultWgPort, + // Remote jobs are an explicit opt-in and default off, including for + // legacy configs (a nil value materializes to false at connect time). + RemoteJobsAllowed: util.False(), + WgPort: iface.DefaultWgPort, } if _, err := config.apply(input); err != nil { @@ -386,6 +431,18 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } + if input.LocalMetricsEnabled != nil && *input.LocalMetricsEnabled != config.LocalMetricsEnabled { + log.Infof("switching local metrics to %t", *input.LocalMetricsEnabled) + config.LocalMetricsEnabled = *input.LocalMetricsEnabled + updated = true + } + + if input.LocalMetricsAddress != nil && *input.LocalMetricsAddress != config.LocalMetricsAddress { + log.Infof("switching local metrics address to %s", *input.LocalMetricsAddress) + config.LocalMetricsAddress = *input.LocalMetricsAddress + updated = true + } + if input.NetworkMonitor != nil && (config.NetworkMonitor == nil || *input.NetworkMonitor != *config.NetworkMonitor) { log.Infof("switching Network Monitor to %t", *input.NetworkMonitor) config.NetworkMonitor = input.NetworkMonitor @@ -454,6 +511,21 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } + if input.RemoteJobsAllowed != nil && (config.RemoteJobsAllowed == nil || *input.RemoteJobsAllowed != *config.RemoteJobsAllowed) { + if *input.RemoteJobsAllowed { + log.Infof("enabling remote jobs") + } else { + log.Infof("disabling remote jobs") + } + config.RemoteJobsAllowed = input.RemoteJobsAllowed + updated = true + } else if config.RemoteJobsAllowed == nil { + // Remote jobs are an explicit opt-in: unlike SSH, a pre-existing config + // with no value defaults to disabled rather than being turned on. + config.RemoteJobsAllowed = util.False() + updated = true + } + if input.EnableSSHRoot != nil && (config.EnableSSHRoot == nil || *input.EnableSSHRoot != *config.EnableSSHRoot) { if *input.EnableSSHRoot { log.Infof("enabling SSH root login") @@ -587,6 +659,12 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } + if input.SyncMessageVersion != nil && *input.SyncMessageVersion != *config.SyncMessageVersion { + log.Infof("setting SyncMessageVersion to %v", *input.SyncMessageVersion) + *config.SyncMessageVersion = *input.SyncMessageVersion + updated = true + } + if input.DisableNotifications != nil && (config.DisableNotifications == nil || *input.DisableNotifications != *config.DisableNotifications) { if *input.DisableNotifications { log.Infof("disabling notifications") @@ -642,9 +720,11 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - // MDM is the last override layer: any key present in the policy - // supersedes defaults, on-disk config, env vars and CLI input. - config.applyMDMPolicy(loadMDMPolicy()) + // Initialise the MDM overlay to "no enforcement" so Config.Policy() + // never returns a stale or nil policy on a freshly applied Config. + // Lifecycle owners that want to enforce a real MDM policy invoke + // Config.ApplyMDMPolicy(loader.Load()) after this returns. + config.applyMDMPolicy(mdm.NewPolicy(nil)) return updated, nil } @@ -657,6 +737,14 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { // for the key, so per-field rejection of user writes still applies). func (config *Config) applyMDMPolicy(policy *mdm.Policy) { config.policy = policy + + // DebugBundleUploadURL is a runtime-only override re-derived from MDM on + // every apply. Resolve it unconditionally (before the IsEmpty early return) + // so a policy that drops the key, becomes empty, or carries an invalid + // value can never leave a previously-enforced upload target active on a + // reused Config instance. + config.DebugBundleUploadURL = mdmDebugBundleUploadURL(policy) + if policy.IsEmpty() { return } @@ -704,12 +792,19 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) { } applyBool(mdm.KeyAllowServerSSH, func(v bool) { bv := v; config.ServerSSHAllowed = &bv }) + applyBool(mdm.KeyRemoteJobsAllowed, func(v bool) { bv := v; config.RemoteJobsAllowed = &bv }) applyBool(mdm.KeyDisableClientRoutes, func(v bool) { config.DisableClientRoutes = v }) applyBool(mdm.KeyDisableServerRoutes, func(v bool) { config.DisableServerRoutes = v }) applyBool(mdm.KeyBlockInbound, func(v bool) { config.BlockInbound = v }) applyBool(mdm.KeyDisableAutoConnect, func(v bool) { config.DisableAutoConnect = v }) applyBool(mdm.KeyRosenpassEnabled, func(v bool) { config.RosenpassEnabled = v }) applyBool(mdm.KeyRosenpassPermissive, func(v bool) { config.RosenpassPermissive = v }) + applyBool(mdm.KeyEnableLocalMetrics, func(v bool) { config.LocalMetricsEnabled = v }) + + if v, ok := policy.GetString(mdm.KeyLocalMetricsAddress); ok { + config.LocalMetricsAddress = v + logApplied(mdm.KeyLocalMetricsAddress, v) + } if v, ok := policy.GetInt(mdm.KeyWireguardPort); ok { // REG_DWORD is 32-bit; UDP port range is 1-65535. Clamp at the @@ -731,6 +826,52 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) { config.LazyConnection = state logApplied(mdm.KeyLazyConnection, state) } + +} + +// ValidateBundleUploadURL sanity-checks a debug-bundle upload URL. An empty +// value is accepted — the executor falls back to the default upload service. A +// non-empty value must be a well-formed https URL with a host; a malformed +// value or a plaintext scheme is rejected. It deliberately does not constrain +// which host may receive the bundle. This is the single source of truth for the +// rule, shared by the remote-job executor (client/internal) and the MDM policy +// override below so the two validation paths cannot drift. +func ValidateBundleUploadURL(raw string) error { + if raw == "" { + return nil + } + parsed, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("parse upload URL: %w", err) + } + // Hostname(), not Host: an authority like ":443" is non-empty but has no + // host, and would fail the actual upload. + if parsed.Scheme != "https" || parsed.Hostname() == "" { + return fmt.Errorf("upload URL must be an https URL with a host") + } + return nil +} + +// mdmDebugBundleUploadURL resolves the MDM-enforced debug-bundle upload URL +// override from the policy, returning the empty string when the policy does +// not carry a valid KeyBundleUploadURL. An absent or invalid value fails +// closed to "" so it falls back to the management-supplied or default upload +// target rather than a previously-enforced one. The URL is never logged: it +// can embed credentials or signed query tokens (KeyBundleUploadURL is in +// mdm.SecretKeys). +func mdmDebugBundleUploadURL(policy *mdm.Policy) string { + v, ok := policy.GetString(mdm.KeyBundleUploadURL) + if !ok || v == "" { + return "" + } + // Must be a well-formed https URL with a host, matching the client's + // remote-job upload-URL validation (shared validator, single source of truth). + if err := ValidateBundleUploadURL(v); err != nil { + log.Warnf("MDM debug bundle upload URL is invalid (must be an https URL with a host); ignoring the override") + return "" + } + log.Infof("MDM override %s = ********** (secret)", mdm.KeyBundleUploadURL) + return v } // parseURL parses and validates the URL for the named service. The URL @@ -738,6 +879,13 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) { // appended for https or ":80" for http. The serviceName parameter is // used to contextualise error messages. On success returns the parsed // *url.URL; on failure returns a non-nil error. +// ParseServiceURL normalises a service URL exactly as the config layer does when +// it stores one, so callers comparing a requested URL against a stored one do not +// have to reimplement the scheme validation and default-port handling. +func ParseServiceURL(serviceName, serviceURL string) (*url.URL, error) { + return parseURL(serviceName, serviceURL) +} + func parseURL(serviceName, serviceURL string) (*url.URL, error) { parsedMgmtURL, err := url.ParseRequestURI(serviceURL) if err != nil { diff --git a/client/internal/profilemanager/config_mdm.go b/client/internal/profilemanager/config_mdm.go new file mode 100644 index 000000000..25b9f18f7 --- /dev/null +++ b/client/internal/profilemanager/config_mdm.go @@ -0,0 +1,52 @@ +package profilemanager + +import ( + "errors" + "fmt" + + "github.com/netbirdio/netbird/client/mdm" +) + +// ErrMDMManagedFields marks a config change rejected because it diverges from +// MDM-enforced values. +var ErrMDMManagedFields = errors.New("fields managed by MDM cannot be modified") + +// MDMConflicts returns the names of MDM-managed keys whose requested value in +// the ConfigInput differs from the policy-enforced value; a field set to the +// enforced value is a no-op echo, not a conflict. +func MDMConflicts(input ConfigInput, policy *mdm.Policy) []string { + pskGot := input.PreSharedKey + if isPreSharedKeyHidden(pskGot) { + pskGot = nil + } + var port *int64 + if input.WireguardPort != nil { + v := int64(*input.WireguardPort) + port = &v + } + return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{ + mdm.ConflictURL(mdm.KeyManagementURL, input.ManagementURL), + mdm.ConflictStringPtr(mdm.KeyPreSharedKey, pskGot), + mdm.ConflictBool(mdm.KeyRosenpassEnabled, input.RosenpassEnabled), + mdm.ConflictBool(mdm.KeyRosenpassPermissive, input.RosenpassPermissive), + mdm.ConflictBool(mdm.KeyDisableAutoConnect, input.DisableAutoConnect), + mdm.ConflictBool(mdm.KeyAllowServerSSH, input.ServerSSHAllowed), + mdm.ConflictBool(mdm.KeyRemoteJobsAllowed, input.RemoteJobsAllowed), + mdm.ConflictBool(mdm.KeyDisableClientRoutes, input.DisableClientRoutes), + mdm.ConflictBool(mdm.KeyDisableServerRoutes, input.DisableServerRoutes), + mdm.ConflictBool(mdm.KeyBlockInbound, input.BlockInbound), + mdm.ConflictInt64(mdm.KeyWireguardPort, port), + mdm.ConflictBool(mdm.KeyEnableLocalMetrics, input.LocalMetricsEnabled), + mdm.ConflictStringPtr(mdm.KeyLocalMetricsAddress, input.LocalMetricsAddress), + }) +} + +// CheckMDMConflicts returns an ErrMDMManagedFields-wrapped error naming the +// conflicting keys, or nil when the input does not fight the policy. +func CheckMDMConflicts(input ConfigInput, policy *mdm.Policy) error { + conflicts := MDMConflicts(input, policy) + if len(conflicts) == 0 { + return nil + } + return fmt.Errorf("%w: %v", ErrMDMManagedFields, conflicts) +} diff --git a/client/internal/profilemanager/config_mdm_test.go b/client/internal/profilemanager/config_mdm_test.go index c6a688ab2..716b7a553 100644 --- a/client/internal/profilemanager/config_mdm_test.go +++ b/client/internal/profilemanager/config_mdm_test.go @@ -10,24 +10,58 @@ import ( "github.com/netbirdio/netbird/client/mdm" ) -// withMDMPolicy temporarily overrides the package-level loadMDMPolicy hook so -// apply() observes the supplied Policy. The original loader is restored at -// test cleanup. -func withMDMPolicy(t *testing.T, policy *mdm.Policy) { +// fakeFetcher implements mdm.PolicyFetcher returning a pre-set policy +// map. Test helper used to construct a Loader without touching the OS +// or any package-level state. +type fakeFetcher struct{ values map[string]any } + +func (f *fakeFetcher) Fetch() map[string]any { return f.values } + +// loaderFor builds an mdm.Loader whose loadPlatform returns the +// supplied Policy's underlying values. +func loaderFor(policy *mdm.Policy) *mdm.Loader { + if policy == nil || policy.IsEmpty() { + return mdm.NewLoader(&fakeFetcher{values: nil}) + } + values := make(map[string]any) + for _, k := range policy.ManagedKeys() { + if v, ok := policy.GetString(k); ok { + values[k] = v + continue + } + if v, ok := policy.GetInt(k); ok { + values[k] = v + continue + } + if v, ok := policy.GetBool(k); ok { + values[k] = v + continue + } + if v, ok := policy.GetStringSlice(k); ok { + values[k] = v + } + } + return mdm.NewLoader(&fakeFetcher{values: values}) +} + +// configWithMDM is the test convenience that builds a Config via +// UpdateOrCreateConfig and overlays the supplied MDM policy on top — +// mirrors the production pattern (Server.getConfig / Client.applyMDMOverlay) +// where the Loader lives outside Config and the apply step is driven +// by the lifecycle owner. +func configWithMDM(t *testing.T, input ConfigInput, policy *mdm.Policy) *Config { t.Helper() - prev := loadMDMPolicy - loadMDMPolicy = func() *mdm.Policy { return policy } - t.Cleanup(func() { loadMDMPolicy = prev }) + cfg, err := UpdateOrCreateConfig(input) + require.NoError(t, err) + require.NotNil(t, cfg) + cfg.ApplyMDMPolicy(loaderFor(policy).Load()) + return cfg } func TestApply_MDMEmpty_NoEnforcement(t *testing.T) { - withMDMPolicy(t, mdm.NewPolicy(nil)) - - cfg, err := UpdateOrCreateConfig(ConfigInput{ + cfg := configWithMDM(t, ConfigInput{ ConfigPath: filepath.Join(t.TempDir(), "config.json"), - }) - require.NoError(t, err) - require.NotNil(t, cfg) + }, mdm.NewPolicy(nil)) assert.True(t, cfg.Policy().IsEmpty(), "no MDM source ⇒ empty Policy") assert.False(t, cfg.Policy().HasKey(mdm.KeyManagementURL)) @@ -39,18 +73,15 @@ func TestApply_MDMEmpty_NoEnforcement(t *testing.T) { func TestApply_MDMOnly_OverridesDefaults(t *testing.T) { const mdmURL = "https://corp.mdm.example.com:443" - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + + cfg := configWithMDM(t, ConfigInput{ + ConfigPath: filepath.Join(t.TempDir(), "config.json"), + }, mdm.NewPolicy(map[string]any{ mdm.KeyManagementURL: mdmURL, mdm.KeyDisableClientRoutes: true, mdm.KeyBlockInbound: true, })) - cfg, err := UpdateOrCreateConfig(ConfigInput{ - ConfigPath: filepath.Join(t.TempDir(), "config.json"), - }) - require.NoError(t, err) - require.NotNil(t, cfg) - assert.Equal(t, mdmURL, cfg.ManagementURL.String()) assert.True(t, cfg.DisableClientRoutes) assert.True(t, cfg.BlockInbound) @@ -65,16 +96,12 @@ func TestApply_MDMBeatsCLIInput(t *testing.T) { const mdmURL = "https://mdm.example.com:443" const cliURL = "https://cli.example.com:443" - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ - mdm.KeyManagementURL: mdmURL, - })) - - cfg, err := UpdateOrCreateConfig(ConfigInput{ + cfg := configWithMDM(t, ConfigInput{ ConfigPath: filepath.Join(t.TempDir(), "config.json"), ManagementURL: cliURL, - }) - require.NoError(t, err) - require.NotNil(t, cfg) + }, mdm.NewPolicy(map[string]any{ + mdm.KeyManagementURL: mdmURL, + })) // MDM wins over CLI-supplied management URL. assert.Equal(t, mdmURL, cfg.ManagementURL.String()) @@ -82,16 +109,12 @@ func TestApply_MDMBeatsCLIInput(t *testing.T) { } func TestApply_MDMInvalidURL_KeepsPreviousValue(t *testing.T) { - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + cfg := configWithMDM(t, ConfigInput{ + ConfigPath: filepath.Join(t.TempDir(), "config.json"), + }, mdm.NewPolicy(map[string]any{ mdm.KeyManagementURL: "not-a-url", })) - cfg, err := UpdateOrCreateConfig(ConfigInput{ - ConfigPath: filepath.Join(t.TempDir(), "config.json"), - }) - require.NoError(t, err) - require.NotNil(t, cfg) - // Invalid MDM URL is logged and skipped: default URL stays in place // to keep the client functional. assert.Equal(t, DefaultManagementURL, cfg.ManagementURL.String()) @@ -106,30 +129,49 @@ func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) { tmp := filepath.Join(t.TempDir(), "config.json") // Seed without MDM. - withMDMPolicy(t, mdm.NewPolicy(nil)) - _, err := UpdateOrCreateConfig(ConfigInput{ + configWithMDM(t, ConfigInput{ ConfigPath: tmp, DisableClientRoutes: boolPtr(false), RosenpassEnabled: boolPtr(false), - }) - require.NoError(t, err) + }, mdm.NewPolicy(nil)) // Now enable MDM enforcement for these keys. - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + cfg := configWithMDM(t, ConfigInput{ + ConfigPath: tmp, + }, mdm.NewPolicy(map[string]any{ mdm.KeyDisableClientRoutes: true, mdm.KeyRosenpassEnabled: true, })) - cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp}) - require.NoError(t, err) - require.NotNil(t, cfg) - assert.True(t, cfg.DisableClientRoutes, "MDM override should flip on-disk false to true") assert.True(t, cfg.RosenpassEnabled) assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableClientRoutes)) assert.True(t, cfg.Policy().HasKey(mdm.KeyRosenpassEnabled)) } +func TestApply_MDMLocalMetrics(t *testing.T) { + tmp := filepath.Join(t.TempDir(), "config.json") + + // Seed without MDM. + configWithMDM(t, ConfigInput{ + ConfigPath: tmp, + LocalMetricsEnabled: boolPtr(false), + }, mdm.NewPolicy(nil)) + + // Now enable MDM enforcement for these keys. + cfg := configWithMDM(t, ConfigInput{ + ConfigPath: tmp, + }, mdm.NewPolicy(map[string]any{ + mdm.KeyEnableLocalMetrics: true, + mdm.KeyLocalMetricsAddress: "127.0.0.1:9292", + })) + + assert.True(t, cfg.LocalMetricsEnabled, "MDM override should flip on-disk false to true") + assert.Equal(t, "127.0.0.1:9292", cfg.LocalMetricsAddress) + assert.True(t, cfg.Policy().HasKey(mdm.KeyEnableLocalMetrics)) + assert.True(t, cfg.Policy().HasKey(mdm.KeyLocalMetricsAddress)) +} + func TestApply_MDMLazyConnection(t *testing.T) { cases := []struct { name string @@ -145,16 +187,12 @@ func TestApply_MDMLazyConnection(t *testing.T) { } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + cfg := configWithMDM(t, ConfigInput{ + ConfigPath: filepath.Join(t.TempDir(), "config.json"), + }, mdm.NewPolicy(map[string]any{ mdm.KeyLazyConnection: c.raw, })) - cfg, err := UpdateOrCreateConfig(ConfigInput{ - ConfigPath: filepath.Join(t.TempDir(), "config.json"), - }) - require.NoError(t, err) - require.NotNil(t, cfg) - assert.Equal(t, c.want, cfg.LazyConnection) assert.True(t, cfg.Policy().HasKey(mdm.KeyLazyConnection)) }) @@ -162,22 +200,83 @@ func TestApply_MDMLazyConnection(t *testing.T) { } func TestApply_MDMPreSharedKeyRedactionSentinelRejected(t *testing.T) { - const maskSentinel = "**********" + const maskSentinel = mdm.PreSharedKeyRedactedSentinel - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + cfg := configWithMDM(t, ConfigInput{ + ConfigPath: filepath.Join(t.TempDir(), "config.json"), + }, mdm.NewPolicy(map[string]any{ mdm.KeyPreSharedKey: maskSentinel, })) - cfg, err := UpdateOrCreateConfig(ConfigInput{ - ConfigPath: filepath.Join(t.TempDir(), "config.json"), - }) - require.NoError(t, err) - require.NotNil(t, cfg) - // Mask sentinel must not be persisted as the actual PSK. assert.NotEqual(t, maskSentinel, cfg.PreSharedKey) // Key still marked managed so user writes are still rejected. assert.True(t, cfg.Policy().HasKey(mdm.KeyPreSharedKey)) } +func TestMDMConflicts_PreSharedKey(t *testing.T) { + policy := mdm.NewPolicy(map[string]any{ + mdm.KeyPreSharedKey: "mdm-enforced-psk", + }) + empty := "" + sentinel := mdm.PreSharedKeyRedactedSentinel + same := "mdm-enforced-psk" + other := "user-psk" + + tests := []struct { + name string + psk *string + want []string + }{ + {name: "unset", psk: nil, want: nil}, + {name: "explicit empty", psk: &empty, want: []string{mdm.KeyPreSharedKey}}, + {name: "sentinel echo", psk: &sentinel, want: nil}, + {name: "same value", psk: &same, want: nil}, + {name: "divergent", psk: &other, want: []string{mdm.KeyPreSharedKey}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, MDMConflicts(ConfigInput{PreSharedKey: tc.psk}, policy)) + }) + } +} + +func TestMDMConflicts_RemoteJobsAndLocalMetrics(t *testing.T) { + policy := mdm.NewPolicy(map[string]any{ + mdm.KeyRemoteJobsAllowed: false, + mdm.KeyEnableLocalMetrics: true, + mdm.KeyLocalMetricsAddress: "127.0.0.1:9999", + }) + sameAddr := "127.0.0.1:9999" + otherAddr := "0.0.0.0:9999" + emptyAddr := "" + + tests := []struct { + name string + input ConfigInput + want []string + }{ + {name: "unset", input: ConfigInput{}, want: nil}, + {name: "echo", input: ConfigInput{ + RemoteJobsAllowed: boolPtr(false), + LocalMetricsEnabled: boolPtr(true), + LocalMetricsAddress: &sameAddr, + }, want: nil}, + {name: "remote jobs divergent", input: ConfigInput{RemoteJobsAllowed: boolPtr(true)}, want: []string{mdm.KeyRemoteJobsAllowed}}, + {name: "metrics disabled", input: ConfigInput{LocalMetricsEnabled: boolPtr(false)}, want: []string{mdm.KeyEnableLocalMetrics}}, + {name: "metrics address divergent", input: ConfigInput{LocalMetricsAddress: &otherAddr}, want: []string{mdm.KeyLocalMetricsAddress}}, + {name: "metrics address explicit empty", input: ConfigInput{LocalMetricsAddress: &emptyAddr}, want: []string{mdm.KeyLocalMetricsAddress}}, + {name: "all divergent", input: ConfigInput{ + RemoteJobsAllowed: boolPtr(true), + LocalMetricsEnabled: boolPtr(false), + LocalMetricsAddress: &otherAddr, + }, want: []string{mdm.KeyRemoteJobsAllowed, mdm.KeyEnableLocalMetrics, mdm.KeyLocalMetricsAddress}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, MDMConflicts(tc.input, policy)) + }) + } +} + func boolPtr(b bool) *bool { return &b } diff --git a/client/internal/profilemanager/config_test.go b/client/internal/profilemanager/config_test.go index 736ff3412..248920b5e 100644 --- a/client/internal/profilemanager/config_test.go +++ b/client/internal/profilemanager/config_test.go @@ -14,6 +14,7 @@ import ( "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/internal/routemanager/dynamic" + "github.com/netbirdio/netbird/client/mdm" "github.com/netbirdio/netbird/util" ) @@ -271,6 +272,83 @@ func TestUpdateConfigServerSSHAllowedNotSet(t *testing.T) { } } +func TestUpdateConfigRemoteJobsAllowed(t *testing.T) { + // Unlike SSH (which defaults on for legacy configs), remote jobs are an + // explicit opt-in: a pre-existing config with no value materializes to off. + t.Run("legacy config defaults off", func(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + require.NoError(t, os.WriteFile(configPath, []byte("{}"), 0600)) + + config, err := UpdateConfig(ConfigInput{ConfigPath: configPath}) + require.NoError(t, err) + require.NotNil(t, config.RemoteJobsAllowed, "RemoteJobsAllowed should be materialized") + assert.False(t, *config.RemoteJobsAllowed, "remote jobs must default off") + }) + + for _, tt := range []struct { + name string + input *bool + want bool + }{ + {"enable", util.True(), true}, + {"disable", util.False(), false}, + } { + t.Run(tt.name, func(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + require.NoError(t, os.WriteFile(configPath, []byte("{}"), 0600)) + + config, err := UpdateConfig(ConfigInput{ConfigPath: configPath, RemoteJobsAllowed: tt.input}) + require.NoError(t, err) + require.NotNil(t, config.RemoteJobsAllowed) + assert.Equal(t, tt.want, *config.RemoteJobsAllowed) + }) + } +} + +func TestApplyMDMPolicyRemoteJobs(t *testing.T) { + t.Run("enables remote jobs and sets the upload URL override", func(t *testing.T) { + cfg := &Config{} + cfg.applyMDMPolicy(mdm.NewPolicy(map[string]any{ + mdm.KeyRemoteJobsAllowed: true, + mdm.KeyBundleUploadURL: "https://upload.example.com", + })) + require.NotNil(t, cfg.RemoteJobsAllowed) + assert.True(t, *cfg.RemoteJobsAllowed, "MDM allowRemoteJobs must enable the flag") + assert.Equal(t, "https://upload.example.com", cfg.DebugBundleUploadURL, "MDM upload URL override must be applied") + }) + + t.Run("a non-https upload URL is rejected", func(t *testing.T) { + cfg := &Config{} + cfg.applyMDMPolicy(mdm.NewPolicy(map[string]any{ + mdm.KeyBundleUploadURL: "http://insecure.example.com", + })) + assert.Empty(t, cfg.DebugBundleUploadURL, "a non-https upload URL must be skipped") + }) + + t.Run("dropping the key clears a previously-applied override", func(t *testing.T) { + cfg := &Config{DebugBundleUploadURL: "https://old.example.com"} + // A replacement policy that no longer carries the key must not leave + // the old upload target directing bundles. + cfg.applyMDMPolicy(mdm.NewPolicy(map[string]any{mdm.KeyRemoteJobsAllowed: true})) + assert.Empty(t, cfg.DebugBundleUploadURL, "the stale upload URL override must be cleared") + }) + + t.Run("an empty replacement policy clears a previously-applied override", func(t *testing.T) { + cfg := &Config{DebugBundleUploadURL: "https://old.example.com"} + // A policy that becomes empty entirely hits the IsEmpty early return; + // the override must still be cleared rather than surviving on the + // reused Config instance. + cfg.applyMDMPolicy(mdm.NewPolicy(map[string]any{})) + assert.Empty(t, cfg.DebugBundleUploadURL, "the stale upload URL override must be cleared when the policy empties") + }) + + t.Run("an invalid upload URL clears a previously-applied override (fail closed)", func(t *testing.T) { + cfg := &Config{DebugBundleUploadURL: "https://old.example.com"} + cfg.applyMDMPolicy(mdm.NewPolicy(map[string]any{mdm.KeyBundleUploadURL: "not-a-url"})) + assert.Empty(t, cfg.DebugBundleUploadURL, "an invalid override must fail closed, not keep the stale target") + }) +} + func TestUpdateOldManagementURL(t *testing.T) { origProber := newMgmProber newMgmProber = func(_ context.Context, _ string, _ wgtypes.Key, _ bool) (mgmProber, error) { diff --git a/client/internal/profilemanager/invoking_user.go b/client/internal/profilemanager/invoking_user.go new file mode 100644 index 000000000..7ba612ffb --- /dev/null +++ b/client/internal/profilemanager/invoking_user.go @@ -0,0 +1,121 @@ +package profilemanager + +import ( + "fmt" + "os" + "os/user" + "path/filepath" + "runtime" + "strconv" + + log "github.com/sirupsen/logrus" +) + +const envSudoUser = "SUDO_USER" + +var ( + currentUser = user.Current + getegid = os.Getegid + geteuid = os.Geteuid + lookupUser = user.Lookup +) + +// InvokingUser returns the user a CLI invocation acts for. Under sudo that is +// the user who ran sudo, not root: privileged flags force commands through +// sudo, and resolving profiles as root would silently switch the daemon to +// root's (default) profile instead of the invoking user's. An unmapped positive +// process UID uses its numeric kernel identity; root, sudo lookup failures, and +// unavailable platform identities still fail closed. Privilege decisions stay +// on the kernel credentials of the daemon connection, which SUDO_USER (a plain +// environment variable) can never influence; a forged value only selects a +// profile root could select anyway. +func InvokingUser() (*user.User, error) { + if u, ok := sudoInvokingUser(); ok { + return u, nil + } + // Fail closed instead of falling through to root: every caller feeds this + // username into profile-path resolution, so a lookup failure would resolve + // (and create) a root-owned profile namespace and switch the daemon onto it + // behind the invoking user's back. + if sudoActive() { + return nil, fmt.Errorf("resolve sudo invoking user %q: refusing to fall back to root", os.Getenv(envSudoUser)) + } + u, err := currentUser() + if err == nil { + return u, nil + } + + uid := geteuid() + if uid <= 0 { + return nil, err + } + + log.Debugf("current user lookup for UID %d: %v; using numeric UID", uid, err) + uidString := strconv.Itoa(uid) + return &user.User{ + Username: uidString, + Uid: uidString, + Gid: strconv.Itoa(getegid()), + }, nil +} + +// IsPlainRoot reports that the process runs as root with no usable sudo +// context: there is no invoking user to act for, so per-user resolution falls +// back to root's own (empty) state. Callers use it to refuse ambiguous +// operations instead of silently acting on the wrong profile. +func IsPlainRoot() bool { + if geteuid() != 0 { + return false + } + _, ok := sudoInvokingUser() + return !ok +} + +// MirrorIsAuthoritative reports whether the invoking user's local +// active-profile mirror can be trusted as the profile selector. It cannot under +// sudo (writes to it are skipped, so it goes stale) or as plain root (there is +// no invoking user, so it falls back to root's own default). Callers use it to +// decide whether to read the profile from the mirror or from the daemon. +func MirrorIsAuthoritative() bool { + return !sudoActive() && !IsPlainRoot() +} + +// sudoInvokingUser resolves SUDO_USER when the process runs as root under +// sudo. Returns false whenever the sudo context is absent or unusable, in +// which case callers fall back to the process user. +func sudoInvokingUser() (*user.User, bool) { + if !sudoActive() { + return nil, false + } + name := os.Getenv(envSudoUser) + u, err := lookupUser(name) + if err != nil { + log.Warnf("sudo invoking user %q lookup: %v", name, err) + return nil, false + } + return u, true +} + +// sudoActive reports a sudo context from the environment alone: write-skip +// decisions key off it so a transient user lookup failure can never flip a +// run from read-only to writing root-owned files into the user's directory. +func sudoActive() bool { + if geteuid() != 0 { + return false + } + name := os.Getenv(envSudoUser) + return name != "" && name != "root" +} + +// userBaseConfigDir mirrors os.UserConfigDir for a user other than the process +// owner. Environment overrides (XDG_CONFIG_HOME) cannot be honoured here: under +// sudo the environment is root's, not the invoking user's. +func userBaseConfigDir(u *user.User) (string, error) { + if u.HomeDir == "" { + return "", fmt.Errorf("user %s has no home directory", u.Username) + } + if runtime.GOOS == "darwin" { + return filepath.Join(u.HomeDir, "Library", "Application Support"), nil + } + return filepath.Join(u.HomeDir, ".config"), nil +} diff --git a/client/internal/profilemanager/invoking_user_test.go b/client/internal/profilemanager/invoking_user_test.go new file mode 100644 index 000000000..159d2616b --- /dev/null +++ b/client/internal/profilemanager/invoking_user_test.go @@ -0,0 +1,298 @@ +package profilemanager + +import ( + "errors" + "fmt" + "io/fs" + "os" + "os/user" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInvokingUserFallsBackToProcessUser(t *testing.T) { + t.Setenv(envSudoUser, "") + + got, err := InvokingUser() + require.NoError(t, err) + + current, err := user.Current() + require.NoError(t, err) + assert.Equal(t, current.Username, got.Username, "invoking user should match the process user without sudo") +} + +func TestInvokingUserFailsClosedWithoutPositiveUID(t *testing.T) { + for _, uid := range []int{0, -1} { + t.Run(fmt.Sprintf("UID%d", uid), func(t *testing.T) { + t.Setenv(envSudoUser, "") + lookupErr := errors.New("current user unavailable") + fakeUnmappedUser(t, uid, 0, lookupErr) + + got, err := InvokingUser() + require.ErrorIs(t, err, lookupErr) + assert.Nil(t, got, "root or unavailable UID must not become a synthetic identity") + }) + } +} + +func TestProfileFilePathUsesNumericIdentityForUnmappedNonRoot(t *testing.T) { + t.Setenv(envSudoUser, "") + fakeUnmappedUser(t, 1001230000, 0, errors.New("user: unknown userid 1001230000")) + + profilesRoot := t.TempDir() + origDir := DefaultConfigPathDir + origOverride := ConfigDirOverride + DefaultConfigPathDir = profilesRoot + ConfigDirOverride = "" + t.Cleanup(func() { + DefaultConfigPathDir = origDir + ConfigDirOverride = origOverride + }) + + profileID := ID("0123456789abcdef0123456789abcdef") + got, err := (&Profile{ID: profileID}).FilePath() + require.NoError(t, err) + assert.Equal(t, + filepath.Join(profilesRoot, "1001230000", profileID.String()+".json"), + got, + "profile path should use the numeric UID namespace", + ) + + entries, err := os.ReadDir(profilesRoot) + require.NoError(t, err) + require.Len(t, entries, 1, "only the numeric UID directory should be created") + assert.Equal(t, "1001230000", entries[0].Name(), "profile namespace should be numeric") + assert.True(t, entries[0].IsDir(), "profile namespace should be a directory") +} + +func TestSudoInvokingUserInactiveWithoutSudoContext(t *testing.T) { + t.Setenv(envSudoUser, "") + _, ok := sudoInvokingUser() + assert.False(t, ok) +} + +func TestSudoInvokingUserIgnoresRoot(t *testing.T) { + t.Setenv(envSudoUser, "root") + origEuid := geteuid + geteuid = func() int { return 0 } + t.Cleanup(func() { geteuid = origEuid }) + + _, ok := sudoInvokingUser() + assert.False(t, ok, "sudo from a root shell must not redirect anything") + assert.False(t, sudoActive()) + assert.True(t, IsPlainRoot()) +} + +func TestSudoInvokingUserResolvesInvokingUser(t *testing.T) { + fakeSudo(t, filepath.Join("/home", "misha")) + + u, ok := sudoInvokingUser() + require.True(t, ok) + assert.Equal(t, "misha", u.Username) + + got, err := InvokingUser() + require.NoError(t, err) + assert.Equal(t, "misha", got.Username) + + assert.False(t, IsPlainRoot()) +} + +func TestInvokingUserFailsClosedWhenSudoLookupFails(t *testing.T) { + fakeSudo(t, filepath.Join("/home", "misha")) + lookupUser = func(string) (*user.User, error) { return nil, errors.New("nss unavailable") } + + origCurrentUser := currentUser + currentUser = func() (*user.User, error) { + t.Fatal("currentUser must not be called after a sudo lookup failure") + return nil, errors.New("currentUser called unexpectedly") + } + t.Cleanup(func() { currentUser = origCurrentUser }) + + got, err := InvokingUser() + require.Error(t, err) + assert.Nil(t, got, "must not resolve to the root process user") +} + +func TestProfileFilePathFailsClosedWhenSudoLookupFails(t *testing.T) { + profilesRoot := t.TempDir() + fakeSudo(t, filepath.Join("/home", "misha")) + lookupUser = func(string) (*user.User, error) { return nil, errors.New("nss unavailable") } + + origDir := DefaultConfigPathDir + DefaultConfigPathDir = profilesRoot + t.Cleanup(func() { DefaultConfigPathDir = origDir }) + + p := &Profile{ID: "0123456789abcdef0123456789abcdef"} + _, err := p.FilePath() + require.Error(t, err) + assertNoEntries(t, profilesRoot) +} + +func TestSudoActiveSurvivesLookupFailure(t *testing.T) { + fakeSudo(t, filepath.Join("/home", "misha")) + lookupUser = func(string) (*user.User, error) { return nil, errors.New("nss unavailable") } + + _, ok := sudoInvokingUser() + assert.False(t, ok) + assert.True(t, sudoActive()) + assert.True(t, IsPlainRoot()) +} + +func TestGetConfigDirUnderSudoIsReadOnly(t *testing.T) { + home := t.TempDir() + fakeSudo(t, home) + + base, err := baseConfigDir() + require.NoError(t, err) + if runtime.GOOS == "darwin" { + assert.Equal(t, filepath.Join(home, "Library", "Application Support"), base) + } else { + assert.Equal(t, filepath.Join(home, ".config"), base) + } + + dir, err := getConfigDir() + require.NoError(t, err) + assert.Equal(t, filepath.Join(base, "netbird"), dir) + assert.NoDirExists(t, dir) +} + +func TestBaseConfigDirFailsClosedWhenSudoLookupFails(t *testing.T) { + fakeSudo(t, filepath.Join("/home", "misha")) + lookupUser = func(string) (*user.User, error) { return nil, errors.New("nss unavailable") } + + _, err := baseConfigDir() + require.Error(t, err) + + _, err = getConfigDir() + require.Error(t, err) +} + +func TestSwitchProfileSkipsStateWriteUnderSudo(t *testing.T) { + home := t.TempDir() + fakeSudo(t, home) + + pm := NewProfileManager() + require.NoError(t, pm.SwitchProfile(defaultProfileName)) + assertNoEntries(t, home) +} + +func TestSetProfileStateSkipsWriteUnderSudo(t *testing.T) { + home := t.TempDir() + fakeSudo(t, home) + + pm := NewProfileManager() + require.NoError(t, pm.SetProfileState(defaultProfileName, &ProfileState{Email: "misha@example.com"})) + assertNoEntries(t, home) +} + +func TestRemoveProfileStateSkipsRemoveUnderSudo(t *testing.T) { + home := t.TempDir() + stateDir := filepath.Join(home, ".config", "netbird") + if runtime.GOOS == "darwin" { + stateDir = filepath.Join(home, "Library", "Application Support", "netbird") + } + require.NoError(t, os.MkdirAll(stateDir, 0o700)) + stateFile := filepath.Join(stateDir, "default.state.json") + require.NoError(t, os.WriteFile(stateFile, []byte(`{"email":"misha@example.com"}`), 0o600)) + + fakeSudo(t, home) + pm := NewProfileManager() + require.NoError(t, pm.RemoveProfileState("default")) + assert.FileExists(t, stateFile) +} + +func TestUserBaseConfigDir(t *testing.T) { + u := &user.User{Username: "misha", HomeDir: filepath.Join("/home", "misha")} + dir, err := userBaseConfigDir(u) + require.NoError(t, err) + if runtime.GOOS == "darwin" { + assert.Equal(t, filepath.Join(u.HomeDir, "Library", "Application Support"), dir) + } else { + assert.Equal(t, filepath.Join(u.HomeDir, ".config"), dir) + } + + _, err = userBaseConfigDir(&user.User{Username: "nohome"}) + require.Error(t, err) +} + +func TestIsPlainRoot(t *testing.T) { + t.Setenv(envSudoUser, "") + origEuid := geteuid + t.Cleanup(func() { geteuid = origEuid }) + + geteuid = func() int { return 1000 } + assert.False(t, IsPlainRoot()) + + geteuid = func() int { return 0 } + assert.True(t, IsPlainRoot()) +} + +func TestMirrorIsAuthoritative(t *testing.T) { + t.Setenv(envSudoUser, "") + origEuid := geteuid + t.Cleanup(func() { geteuid = origEuid }) + + geteuid = func() int { return 1000 } + assert.True(t, MirrorIsAuthoritative(), "a normal user's own mirror is authoritative") + + geteuid = func() int { return 0 } + assert.False(t, MirrorIsAuthoritative(), "plain root has no authoritative mirror") +} + +func TestMirrorIsAuthoritativeFalseUnderSudo(t *testing.T) { + fakeSudo(t, filepath.Join("/home", "misha")) + assert.False(t, MirrorIsAuthoritative(), "the sudo mirror is frozen, so it is not authoritative") +} + +func fakeSudo(t *testing.T, home string) { + t.Helper() + t.Setenv(envSudoUser, "misha") + + origEuid := geteuid + origLookup := lookupUser + origOverride := ConfigDirOverride + geteuid = func() int { return 0 } + lookupUser = func(name string) (*user.User, error) { + return &user.User{Username: name, Uid: "1234", Gid: "1234", HomeDir: home}, nil + } + ConfigDirOverride = "" + t.Cleanup(func() { + geteuid = origEuid + lookupUser = origLookup + ConfigDirOverride = origOverride + }) +} + +func fakeUnmappedUser(t *testing.T, uid, gid int, lookupErr error) { + t.Helper() + + origCurrentUser := currentUser + origEuid := geteuid + origEgid := getegid + currentUser = func() (*user.User, error) { return nil, lookupErr } + geteuid = func() int { return uid } + getegid = func() int { return gid } + t.Cleanup(func() { + currentUser = origCurrentUser + geteuid = origEuid + getegid = origEgid + }) +} + +func assertNoEntries(t *testing.T, root string) { + t.Helper() + err := filepath.WalkDir(root, func(path string, _ fs.DirEntry, err error) error { + if err != nil { + return err + } + if path != root { + t.Errorf("unexpected entry created under %s: %s", root, path) + } + return nil + }) + require.NoError(t, err) +} diff --git a/client/internal/profilemanager/prefs.go b/client/internal/profilemanager/prefs.go new file mode 100644 index 000000000..5613b0be3 --- /dev/null +++ b/client/internal/profilemanager/prefs.go @@ -0,0 +1,130 @@ +package profilemanager + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + + "github.com/netbirdio/netbird/util" +) + +const prefsFileSuffix = ".prefs.json" + +var prefsMu sync.Mutex + +// Prefs is a namespaced per-profile preference store backed by a single JSON +// file next to the profile config; it is deleted together with the profile. +type Prefs struct { + path string +} + +// ProfilePrefs returns the preference store of the profile identified by id. +func (s *ServiceManager) ProfilePrefs(id ID, username string) (*Prefs, error) { + if !IsValidProfileFilenameStem(id) { + return nil, fmt.Errorf("invalid profile ID: %q", id) + } + if id == defaultProfileName { + return &Prefs{path: filepath.Join(filepath.Dir(DefaultConfigPath), id.String()+prefsFileSuffix)}, nil + } + configDir, err := s.getConfigDir(username) + if err != nil { + return nil, fmt.Errorf("get config directory for user %s: %w", username, err) + } + return &Prefs{path: filepath.Join(configDir, id.String()+prefsFileSuffix)}, nil +} + +// Get unmarshals the namespace section into v and reports whether it exists. +func (p *Prefs) Get(namespace string, v any) (bool, error) { + if namespace == "" { + return false, fmt.Errorf("empty prefs namespace") + } + + prefsMu.Lock() + defer prefsMu.Unlock() + + sections, err := readPrefsFile(p.path) + if err != nil { + return false, err + } + raw, ok := sections[namespace] + if !ok { + return false, nil + } + if err := json.Unmarshal(raw, v); err != nil { + return false, fmt.Errorf("decode prefs namespace %q: %w", namespace, err) + } + return true, nil +} + +// Put stores v as the namespace section, replacing any previous value. +func (p *Prefs) Put(namespace string, v any) error { + if namespace == "" { + return fmt.Errorf("empty prefs namespace") + } + raw, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("encode prefs namespace %q: %w", namespace, err) + } + + prefsMu.Lock() + defer prefsMu.Unlock() + + sections, err := readPrefsFile(p.path) + if err != nil { + return err + } + sections[namespace] = raw + return writePrefsFile(p.path, sections) +} + +// Remove deletes the namespace section; a missing one is not an error. +func (p *Prefs) Remove(namespace string) error { + if namespace == "" { + return fmt.Errorf("empty prefs namespace") + } + + prefsMu.Lock() + defer prefsMu.Unlock() + + sections, err := readPrefsFile(p.path) + if err != nil { + return err + } + if _, ok := sections[namespace]; !ok { + return nil + } + delete(sections, namespace) + return writePrefsFile(p.path, sections) +} + +func removePrefsFile(path string) error { + prefsMu.Lock() + defer prefsMu.Unlock() + return os.Remove(path) +} + +func readPrefsFile(path string) (map[string]json.RawMessage, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return map[string]json.RawMessage{}, nil + } + if err != nil { + return nil, fmt.Errorf("read prefs: %w", err) + } + + sections := map[string]json.RawMessage{} + if err := json.Unmarshal(data, §ions); err != nil { + return nil, fmt.Errorf("decode prefs: %w", err) + } + return sections, nil +} + +func writePrefsFile(path string, sections map[string]json.RawMessage) error { + if err := util.WriteJsonWithRestrictedPermission(context.Background(), path, sections); err != nil { + return fmt.Errorf("write prefs: %w", err) + } + return nil +} diff --git a/client/internal/profilemanager/prefs_test.go b/client/internal/profilemanager/prefs_test.go new file mode 100644 index 000000000..692ade70f --- /dev/null +++ b/client/internal/profilemanager/prefs_test.go @@ -0,0 +1,138 @@ +package profilemanager + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type testPrefsSection struct { + Mode uint8 `json:"mode"` + Dest string `json:"dest"` +} + +func TestProfilePrefs_RoundTrip(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + created, err := sm.AddProfile("work", username) + require.NoError(t, err) + + prefs, err := sm.ProfilePrefs(created.ID, username) + require.NoError(t, err) + + require.NoError(t, prefs.Put("filedrop", testPrefsSection{Mode: 2, Dest: "/tmp/x"})) + require.NoError(t, prefs.Put("other", map[string]int{"n": 1})) + + var got testPrefsSection + found, err := prefs.Get("filedrop", &got) + require.NoError(t, err) + assert.True(t, found) + assert.Equal(t, testPrefsSection{Mode: 2, Dest: "/tmp/x"}, got) + + var other map[string]int + found, err = prefs.Get("other", &other) + require.NoError(t, err) + assert.True(t, found) + assert.Equal(t, map[string]int{"n": 1}, other) + }) +} + +func TestProfilePrefs_GetMissingNamespace(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + created, err := sm.AddProfile("work", username) + require.NoError(t, err) + + prefs, err := sm.ProfilePrefs(created.ID, username) + require.NoError(t, err) + + var got testPrefsSection + found, err := prefs.Get("filedrop", &got) + require.NoError(t, err) + assert.False(t, found) + }) +} + +func TestProfilePrefs_RemoveNamespace(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + created, err := sm.AddProfile("work", username) + require.NoError(t, err) + + prefs, err := sm.ProfilePrefs(created.ID, username) + require.NoError(t, err) + + require.NoError(t, prefs.Put("filedrop", testPrefsSection{Mode: 1})) + require.NoError(t, prefs.Put("other", map[string]int{"n": 1})) + require.NoError(t, prefs.Remove("filedrop")) + require.NoError(t, prefs.Remove("missing")) + + var got testPrefsSection + found, err := prefs.Get("filedrop", &got) + require.NoError(t, err) + assert.False(t, found) + + var other map[string]int + found, err = prefs.Get("other", &other) + require.NoError(t, err) + assert.True(t, found) + assert.Equal(t, map[string]int{"n": 1}, other) + }) +} + +func TestProfilePrefs_RejectsInvalidID(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + _, err := sm.ProfilePrefs("../escape", username) + assert.Error(t, err) + }) +} + +func TestProfilePrefs_RejectsEmptyNamespace(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + created, err := sm.AddProfile("work", username) + require.NoError(t, err) + + prefs, err := sm.ProfilePrefs(created.ID, username) + require.NoError(t, err) + + _, err = prefs.Get("", &testPrefsSection{}) + assert.Error(t, err) + assert.Error(t, prefs.Put("", testPrefsSection{})) + assert.Error(t, prefs.Remove("")) + }) +} + +func TestProfilePrefs_DefaultProfile(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + prefs, err := sm.ProfilePrefs(defaultProfileName, username) + require.NoError(t, err) + + require.NoError(t, prefs.Put("filedrop", testPrefsSection{Mode: 1})) + + expected := filepath.Join(filepath.Dir(DefaultConfigPath), "default"+prefsFileSuffix) + _, err = os.Stat(expected) + require.NoError(t, err) + }) +} + +func TestRemoveProfile_DeletesPrefsFile(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + created, err := sm.AddProfile("work", username) + require.NoError(t, err) + + prefs, err := sm.ProfilePrefs(created.ID, username) + require.NoError(t, err) + require.NoError(t, prefs.Put("filedrop", testPrefsSection{Mode: 2})) + + configDir, err := sm.getConfigDir(username) + require.NoError(t, err) + prefsPath := filepath.Join(configDir, created.ID.String()+prefsFileSuffix) + _, err = os.Stat(prefsPath) + require.NoError(t, err) + + require.NoError(t, sm.RemoveProfile(created.ID, username)) + _, err = os.Stat(prefsPath) + assert.True(t, errors.Is(err, os.ErrNotExist), "prefs file should be removed") + }) +} diff --git a/client/internal/profilemanager/profilemanager.go b/client/internal/profilemanager/profilemanager.go index e25d493d5..d2ed92bc5 100644 --- a/client/internal/profilemanager/profilemanager.go +++ b/client/internal/profilemanager/profilemanager.go @@ -3,7 +3,6 @@ package profilemanager import ( "fmt" "os" - "os/user" "path/filepath" "strings" "sync" @@ -54,7 +53,7 @@ func (p *Profile) FilePath() (string, error) { return "", fmt.Errorf("invalid profile ID: %q", id) } - username, err := user.Current() + username, err := InvokingUser() if err != nil { return "", fmt.Errorf("failed to get current user: %w", err) } @@ -130,7 +129,7 @@ func (pm *ProfileManager) getActiveProfileState() ID { if err != nil { if !os.IsNotExist(err) { log.Warnf("failed to read active profile state: %v", err) - } else { + } else if !sudoActive() { if err := pm.setActiveProfileState(defaultProfileName); err != nil { log.Warnf("failed to set default profile state: %v", err) } @@ -148,6 +147,13 @@ func (pm *ProfileManager) getActiveProfileState() ID { } func (pm *ProfileManager) setActiveProfileState(id ID) error { + // The invoking user's state is read-only under sudo — a root-owned file in + // the user's directory would break their own runs. The daemon still records + // the switch on its side; only the user-local bookkeeping is skipped. + if sudoActive() { + log.Infof("running under sudo: not persisting active profile %q for user %s", id, os.Getenv(envSudoUser)) + return nil + } configDir, err := getConfigDir() if err != nil { diff --git a/client/internal/profilemanager/service.go b/client/internal/profilemanager/service.go index 696a60310..ec287f01a 100644 --- a/client/internal/profilemanager/service.go +++ b/client/internal/profilemanager/service.go @@ -420,6 +420,11 @@ func (s *ServiceManager) RemoveProfile(id ID, username string) error { log.Warnf("failed to remove profile state file %s: %v", stateFile, err) } + prefsFile := filepath.Join(filepath.Dir(target.Path), id.String()+prefsFileSuffix) + if err := removePrefsFile(prefsFile); err != nil && !os.IsNotExist(err) { + log.Warnf("failed to remove profile prefs file %s: %v", prefsFile, err) + } + return nil } diff --git a/client/internal/profilemanager/state.go b/client/internal/profilemanager/state.go index 9e9577796..81e6c085f 100644 --- a/client/internal/profilemanager/state.go +++ b/client/internal/profilemanager/state.go @@ -7,6 +7,8 @@ import ( "os" "path/filepath" + log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/util" ) @@ -45,12 +47,44 @@ func (pm *ProfileManager) GetProfileState(id ID) (*ProfileState, error) { return &state, nil } -func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error { +// SetProfileState writes the state file of the profile identified by id. Prefer +// it over SetActiveProfileState whenever the caller knows which profile the data +// belongs to: an SSO login spans seconds of user interaction, and the active +// profile can change during it, which would file the account email under +// whichever profile happened to be active when the flow returned. +func (pm *ProfileManager) SetProfileState(id ID, state *ProfileState) error { configDir, err := getConfigDir() if err != nil { return fmt.Errorf("get config directory: %w", err) } + if id == "" { + return fmt.Errorf("empty profile ID") + } + if id != defaultProfileName && !IsValidProfileFilenameStem(id) { + return fmt.Errorf("invalid profile ID: %q", id) + } + + // The invoking user's state is read-only under sudo. The file only carries + // the account email for the login hint and display, so skipping the write + // costs at most one extra account prompt later — a root-owned file in the + // user's directory would cost every later update instead. + if sudoActive() { + log.Debugf("running under sudo: not persisting profile state for user %s", os.Getenv(envSudoUser)) + return nil + } + + stateFile := filepath.Join(configDir, id.String()+".state.json") + if err := util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state); err != nil { + return fmt.Errorf("write profile state: %w", err) + } + + return nil +} + +// SetActiveProfileState writes the state file of whichever profile is active at +// call time. Use SetProfileState when the target profile is known. +func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error { activeProf, err := pm.GetActiveProfile() if err != nil { if errors.Is(err, ErrNoActiveProfile) { @@ -59,26 +93,21 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error { return fmt.Errorf("get active profile: %w", err) } - id := activeProf.ID - if id != defaultProfileName && !IsValidProfileFilenameStem(id) { - return fmt.Errorf("invalid active profile ID: %q", id) - } - - stateFile := filepath.Join(configDir, id.String()+".state.json") - err = util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state) - if err != nil { - return fmt.Errorf("write profile state: %w", err) - } - - return nil + return pm.SetProfileState(activeProf.ID, state) } // RemoveProfileState deletes the per-profile state file (which holds the // account email used for the SSO login hint and the UI display). Called after -// a successful logout so a logged-out profile no longer shows a stale account -// email. The state file only stores the email, so deleting it is equivalent to -// clearing it; the next SSO login recreates it. A missing file is not an error. +// profile removal; logout keeps the file so the next login can pass the email +// as the login_hint. The state file only stores the email, so deleting it is +// equivalent to clearing it; the next SSO login recreates it. A missing file +// is not an error. func (pm *ProfileManager) RemoveProfileState(profileName string) error { + if sudoActive() { + log.Debugf("running under sudo: not removing profile state for user %s", os.Getenv(envSudoUser)) + return nil + } + configDir, err := getConfigDir() if err != nil { return fmt.Errorf("get config directory: %w", err) diff --git a/client/internal/routemanager/dnsinterceptor/handler.go b/client/internal/routemanager/dnsinterceptor/handler.go index b784cc274..d20f4b944 100644 --- a/client/internal/routemanager/dnsinterceptor/handler.go +++ b/client/internal/routemanager/dnsinterceptor/handler.go @@ -95,7 +95,7 @@ func (d *DnsInterceptor) RemoveRoute() error { // AllowedIPs should use real IPs if d.currentPeerKey != "" { - if _, err := d.allowedIPsRefcounter.Decrement(prefix); err != nil { + if _, err := d.allowedIPsRefcounter.Decrement(prefix, d.currentPeerKey); err != nil { merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %v", prefix, err)) } } @@ -172,7 +172,7 @@ func (d *DnsInterceptor) removeAllowedIP(realPrefix netip.Prefix) error { } // AllowedIPs use real IPs - if _, err := d.allowedIPsRefcounter.Decrement(realPrefix); err != nil { + if _, err := d.allowedIPsRefcounter.Decrement(realPrefix, d.currentPeerKey); err != nil { return fmt.Errorf("remove allowed IP %s: %v", realPrefix, err) } @@ -205,7 +205,7 @@ func (d *DnsInterceptor) RemoveAllowedIPs() error { for _, prefixes := range d.interceptedDomains { for _, prefix := range prefixes { // AllowedIPs use real IPs - if _, err := d.allowedIPsRefcounter.Decrement(prefix); err != nil { + if _, err := d.allowedIPsRefcounter.Decrement(prefix, d.currentPeerKey); err != nil { merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %v", prefix, err)) } } @@ -479,7 +479,7 @@ func (d *DnsInterceptor) removeDNATMappings(realPrefixes []netip.Prefix, logger // internalDnatFw checks if the firewall supports internal DNAT func (d *DnsInterceptor) internalDnatFw() (internalDNATer, bool) { - if d.firewall == nil || runtime.GOOS != "android" { + if d.firewall == nil || d.fakeIPManager == nil || runtime.GOOS != "android" { return nil, false } fw, ok := d.firewall.(internalDNATer) diff --git a/client/internal/routemanager/dynamic/route.go b/client/internal/routemanager/dynamic/route.go index f0efd7b22..bb3b1c59c 100644 --- a/client/internal/routemanager/dynamic/route.go +++ b/client/internal/routemanager/dynamic/route.go @@ -135,7 +135,7 @@ func (r *Route) RemoveAllowedIPs() error { var merr *multierror.Error for _, domainPrefixes := range r.dynamicDomains { for _, prefix := range domainPrefixes { - if _, err := r.allowedIPsRefcounter.Decrement(prefix); err != nil { + if _, err := r.allowedIPsRefcounter.Decrement(prefix, r.currentPeerKey); err != nil { merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %w", prefix, err)) } } @@ -185,7 +185,7 @@ func (r *Route) startResolver(ctx context.Context) { } func (r *Route) update(ctx context.Context) error { - resolved, err := r.resolveDomains() + resolved, err := r.resolveDomains(ctx) if err != nil { if len(resolved) == 0 { return fmt.Errorf("resolve domains: %w", err) @@ -199,9 +199,9 @@ func (r *Route) update(ctx context.Context) error { return nil } -func (r *Route) resolveDomains() (domainMap, error) { +func (r *Route) resolveDomains(ctx context.Context) (domainMap, error) { results := make(chan resolveResult) - go r.resolve(results) + go r.resolve(ctx, results) resolved := domainMap{} var merr *multierror.Error @@ -217,7 +217,7 @@ func (r *Route) resolveDomains() (domainMap, error) { return resolved, nberrors.FormatErrorOrNil(merr) } -func (r *Route) resolve(results chan resolveResult) { +func (r *Route) resolve(ctx context.Context, results chan resolveResult) { var wg sync.WaitGroup for _, d := range r.route.Domains { @@ -225,10 +225,10 @@ func (r *Route) resolve(results chan resolveResult) { go func(domain domain.Domain) { defer wg.Done() - ips, err := r.getIPsFromResolver(domain) + ips, err := r.getIPsFromResolver(ctx, domain) if err != nil { log.Tracef("Failed to resolve domain %s with private resolver: %v", domain.SafeString(), err) - ips, err = net.LookupIP(domain.PunycodeString()) + ips, err = lookupHostIPs(ctx, domain) if err != nil { results <- resolveResult{domain: domain, err: fmt.Errorf("resolve d %s: %w", domain.SafeString(), err)} return @@ -320,7 +320,7 @@ func (r *Route) removeRoutes(prefixes []netip.Prefix) ([]netip.Prefix, error) { merr = multierror.Append(merr, fmt.Errorf("remove dynamic route for IP %s: %w", prefix, err)) } if r.currentPeerKey != "" { - if _, err := r.allowedIPsRefcounter.Decrement(prefix); err != nil { + if _, err := r.allowedIPsRefcounter.Decrement(prefix, r.currentPeerKey); err != nil { merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %w", prefix, err)) } } @@ -364,6 +364,20 @@ func determinePrefixChanges(oldPrefixes, newPrefixes []netip.Prefix) (toAdd, toR return } +// lookupHostIPs resolves d via the system resolver, honoring ctx cancellation. +func lookupHostIPs(ctx context.Context, d domain.Domain) ([]net.IP, error) { + addrs, err := net.DefaultResolver.LookupIPAddr(ctx, d.PunycodeString()) + if err != nil { + return nil, err + } + + ips := make([]net.IP, 0, len(addrs)) + for _, addr := range addrs { + ips = append(ips, addr.IP) + } + return ips, nil +} + func combinePrefixes(oldPrefixes, removedPrefixes, addedPrefixes []netip.Prefix) []netip.Prefix { prefixSet := make(map[netip.Prefix]struct{}) for _, prefix := range oldPrefixes { diff --git a/client/internal/routemanager/dynamic/route_generic.go b/client/internal/routemanager/dynamic/route_generic.go index 56fd63fba..8bc2dd3df 100644 --- a/client/internal/routemanager/dynamic/route_generic.go +++ b/client/internal/routemanager/dynamic/route_generic.go @@ -3,11 +3,12 @@ package dynamic import ( + "context" "net" "github.com/netbirdio/netbird/shared/management/domain" ) -func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) { - return net.LookupIP(domain.PunycodeString()) +func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) { + return lookupHostIPs(ctx, domain) } diff --git a/client/internal/routemanager/dynamic/route_ios.go b/client/internal/routemanager/dynamic/route_ios.go index 1ae281d56..6a3d262b8 100644 --- a/client/internal/routemanager/dynamic/route_ios.go +++ b/client/internal/routemanager/dynamic/route_ios.go @@ -3,6 +3,7 @@ package dynamic import ( + "context" "fmt" "net" "time" @@ -16,7 +17,7 @@ import ( const dialTimeout = 10 * time.Second -func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) { +func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) { privateClient, err := nbdns.GetClientPrivate(r.wgInterface, r.resolverAddr.Addr(), dialTimeout) if err != nil { return nil, fmt.Errorf("error while creating private client: %s", err) @@ -32,7 +33,7 @@ func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) { msg := new(dns.Msg) msg.SetQuestion(fqdn, qtype) - response, _, err := nbdns.ExchangeWithFallback(nil, privateClient, msg, r.resolverAddr.String()) + response, _, err := nbdns.ExchangeWithFallback(ctx, privateClient, msg, r.resolverAddr.String()) if err != nil { if queryErr == nil { queryErr = fmt.Errorf("DNS query for %s (type %d) after %s: %w", domain.SafeString(), qtype, time.Since(startTime), err) diff --git a/client/internal/routemanager/ipfwdstate/ipfwdstate.go b/client/internal/routemanager/ipfwdstate/ipfwdstate.go index 2be1c2ae7..3d571e16b 100644 --- a/client/internal/routemanager/ipfwdstate/ipfwdstate.go +++ b/client/internal/routemanager/ipfwdstate/ipfwdstate.go @@ -2,54 +2,183 @@ package ipfwdstate import ( "fmt" + "sync" log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/client/internal/routemanager/systemops" ) -// IPForwardingState is a struct that keeps track of the IP forwarding state. -// todo: read initial state of the IP forwarding from the system and reset the state based on it. -// todo: separate v4/v6 forwarding state, since the sysctls are independent -// (net.ipv4.ip_forward vs net.ipv6.conf.all.forwarding). Currently the nftables -// manager shares one instance between both routers, which works only because -// EnableIPForwarding enables both sysctls in a single call. +// IPForwardingState tracks v4 and v6 IP-forwarding sysctl enables with +// independent refcounts so a v4-only routing setup doesn't flip v6 sysctls. type IPForwardingState struct { - enabledCounter int + mu sync.Mutex + + v4Count int + v6Count int + + // routingV4/routingV6 track whether the routing path currently holds a + // reference, so repeated EnableRouting calls (one per network-map update) + // hold at most one reference per family and an unpaired DisableRouting + // can't release references held by DNAT rules. + routingV4 bool + routingV6 bool + + wgIfaceName string + v6Saved map[string]int } -func NewIPForwardingState() *IPForwardingState { - return &IPForwardingState{} +// NewIPForwardingState returns a state tracker for the IP-forwarding sysctls. +// wgIfaceName is excluded from the per-interface accept_ra handling. +func NewIPForwardingState(wgIfaceName string) *IPForwardingState { + return &IPForwardingState{wgIfaceName: wgIfaceName} } -func (f *IPForwardingState) RequestForwarding() error { - if f.enabledCounter != 0 { - f.enabledCounter++ +// Counts returns the current v4 and v6 refcounts. Intended for diagnostics +// and tests. +func (f *IPForwardingState) Counts() (v4, v6 int) { + f.mu.Lock() + defer f.mu.Unlock() + return f.v4Count, f.v6Count +} + +// RequestRouting takes the forwarding references for the routing path. It is +// idempotent: while routing already holds a reference, further calls don't +// increment the refcounts, and a v4-only request releases a previously held v6 +// reference. A v6 sysctl failure is logged and not returned so it can't take +// down v4 routing (the sysctl may be unwritable, e.g. read-only /proc/sys or +// IPv6 disabled on the kernel command line); v6 is retried on the next call. +func (f *IPForwardingState) RequestRouting(v6 bool) error { + f.mu.Lock() + defer f.mu.Unlock() + + if !f.routingV4 { + if err := f.requestV4(); err != nil { + return err + } + f.routingV4 = true + } + + if !v6 { + if !f.routingV6 { + return nil + } + f.routingV6 = false + return f.releaseV6() + } + + if f.routingV6 { return nil } - - if err := systemops.EnableIPForwarding(); err != nil { - return fmt.Errorf("failed to enable IP forwarding with sysctl: %w", err) + if err := f.requestV6(); err != nil { + log.Warnf("enable IPv6 forwarding for routing: %v", err) + return nil } - f.enabledCounter = 1 - log.Info("IP forwarding enabled") - + f.routingV6 = true return nil } -func (f *IPForwardingState) ReleaseForwarding() error { - if f.enabledCounter == 0 { - return nil +// ReleaseRouting releases the references RequestRouting holds. Calls without a +// held reference are no-ops. +func (f *IPForwardingState) ReleaseRouting() error { + f.mu.Lock() + defer f.mu.Unlock() + + if f.routingV4 { + f.routingV4 = false + f.releaseV4() } - - if f.enabledCounter > 1 { - f.enabledCounter-- - return nil + if f.routingV6 { + f.routingV6 = false + return f.releaseV6() } + return nil +} - // if failed to disable IP forwarding we anyway decrement the counter - f.enabledCounter = 0 +// RequestForwarding enables the family's forwarding sysctl on first request. +func (f *IPForwardingState) RequestForwarding(v6 bool) error { + f.mu.Lock() + defer f.mu.Unlock() - // todo call systemops.DisableIPForwarding() + if v6 { + return f.requestV6() + } + return f.requestV4() +} + +// ReleaseForwarding decrements the family counter. The last v6 release restores +// what enable captured. v4 stays on: net.ipv4.ip_forward is co-owned by other +// tooling (docker, k8s, libvirt). +func (f *IPForwardingState) ReleaseForwarding(v6 bool) error { + f.mu.Lock() + defer f.mu.Unlock() + + if v6 { + return f.releaseV6() + } + f.releaseV4() + return nil +} + +func (f *IPForwardingState) requestV4() error { + if f.v4Count == 0 { + if err := systemops.EnableV4IPForwarding(); err != nil { + return fmt.Errorf("enable IPv4 forwarding: %w", err) + } + log.Info("IPv4 forwarding enabled") + } + f.v4Count++ + return nil +} + +func (f *IPForwardingState) releaseV4() { + if f.v4Count > 0 { + f.v4Count-- + } +} + +func (f *IPForwardingState) requestV6() error { + if f.v6Count == 0 { + saved, err := systemops.EnableV6IPForwarding(f.wgIfaceName) + if err != nil { + if rerr := systemops.DisableV6IPForwarding(saved); rerr != nil { + log.Warnf("rollback partial v6 sysctls: %v", rerr) + } + return fmt.Errorf("enable IPv6 forwarding: %w", err) + } + // A failed restore on a previous release keeps its saved values; those + // are the true originals, so keep them over what this enable captured. + if f.v6Saved == nil { + f.v6Saved = saved + } else { + for k, v := range saved { + if _, ok := f.v6Saved[k]; !ok { + f.v6Saved[k] = v + } + } + } + log.Info("IPv6 forwarding enabled") + } + f.v6Count++ + return nil +} + +func (f *IPForwardingState) releaseV6() error { + if f.v6Count == 0 { + return nil + } + f.v6Count-- + if f.v6Count > 0 { + return nil + } + + // Keep the saved values on failure so a later release or enable/release + // cycle can still restore them; re-restoring an already-restored key is a + // no-op since the sysctl already holds the desired value. + if err := systemops.DisableV6IPForwarding(f.v6Saved); err != nil { + return fmt.Errorf("disable IPv6 forwarding: %w", err) + } + f.v6Saved = nil + log.Info("IPv6 forwarding disabled") return nil } diff --git a/client/internal/routemanager/ipfwdstate/ipfwdstate_privileged_linux_test.go b/client/internal/routemanager/ipfwdstate/ipfwdstate_privileged_linux_test.go new file mode 100644 index 000000000..b4615ff02 --- /dev/null +++ b/client/internal/routemanager/ipfwdstate/ipfwdstate_privileged_linux_test.go @@ -0,0 +1,39 @@ +//go:build privileged + +package ipfwdstate + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRequestRoutingV6ToV4Transition verifies that a v4-only routing request +// releases a previously held routing-owned v6 reference without touching +// references held by DNAT rules. +func TestRequestRoutingV6ToV4Transition(t *testing.T) { + f := NewIPForwardingState("wt-fwd-test") + + require.NoError(t, f.RequestRouting(true), "request routing with v6") + v4, v6 := f.Counts() + assert.Equal(t, 1, v4, "v4 reference held") + assert.Equal(t, 1, v6, "v6 reference held") + + require.NoError(t, f.RequestRouting(false), "request routing v4-only") + v4, v6 = f.Counts() + assert.Equal(t, 1, v4, "v4 reference kept") + assert.Equal(t, 0, v6, "routing-owned v6 reference released") + + // A DNAT-held reference survives a v4-only routing request. + require.NoError(t, f.RequestForwarding(true), "dnat v6 reference") + require.NoError(t, f.RequestRouting(false), "repeat v4-only request") + _, v6 = f.Counts() + assert.Equal(t, 1, v6, "dnat-held v6 reference survives") + require.NoError(t, f.ReleaseForwarding(true), "release dnat v6 reference") + + require.NoError(t, f.ReleaseRouting(), "release routing") + v4, v6 = f.Counts() + assert.Equal(t, 0, v4, "all v4 references released") + assert.Equal(t, 0, v6, "all v6 references released") +} diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go index 66b24cc5a..981b0c987 100644 --- a/client/internal/routemanager/manager.go +++ b/client/internal/routemanager/manager.go @@ -9,13 +9,13 @@ import ( "net/url" "runtime" "slices" + "sort" "strings" "sync" "sync/atomic" "syscall" "time" - "github.com/google/uuid" "github.com/hashicorp/go-multierror" log "github.com/sirupsen/logrus" "golang.org/x/exp/maps" @@ -52,15 +52,20 @@ type Manager interface { UpdateRoutes(updateSerial uint64, serverRoutes map[route.ID]*route.Route, clientRoutes route.HAMap, useNewDNSRoute bool) error ClassifyRoutes(newRoutes []*route.Route) (map[route.ID]*route.Route, route.HAMap) TriggerSelection(route.HAMap) + SelectRoutes(ids []route.NetID, appendRoute bool) error + DeselectRoutes(ids []route.NetID) error + SelectAllRoutes() + DeselectAllRoutes() GetRouteSelector() *routeselector.RouteSelector GetClientRoutes() route.HAMap GetSelectedClientRoutes() route.HAMap GetActiveClientRoutes() route.HAMap GetClientRoutesWithNetID() map[route.NetID][]*route.Route SetRouteChangeListener(listener listener.NetworkChangeListener) - InitialRouteRange() []string + CurrentRouteRange() []string SetFirewall(firewall.Manager) error SetDNSForwarderPort(port uint16) + ReconcilePeerAllowedIPs(peerKey string) error Stop(stateManager *statemanager.Manager) } @@ -71,10 +76,8 @@ type ManagerConfig struct { WGInterface iface.WGIface StatusRecorder *peer.Status RelayManager *relayClient.Manager - InitialRoutes []*route.Route StateManager *statemanager.Manager DNSServer dns.Server - DNSFeatureFlag bool PeerStore *peerstore.Store DisableClientRoutes bool DisableServerRoutes bool @@ -144,45 +147,12 @@ func NewManager(config ManagerConfig) *DefaultManager { useNoop := netstack.IsEnabled() || config.DisableClientRoutes dm.setupRefCounters(useNoop) - // don't proceed with client routes if it is disabled - if config.DisableClientRoutes { - return dm - } - - if runtime.GOOS == "android" { - dm.setupAndroidRoutes(config) - } return dm } -func (m *DefaultManager) setupAndroidRoutes(config ManagerConfig) { - cr := m.initialClientRoutes(config.InitialRoutes) - routesForComparison := slices.Clone(cr) - - if config.DNSFeatureFlag { - m.fakeIPManager = fakeip.NewManager() - - v4ID := uuid.NewString() - fakeIPRoute := &route.Route{ - ID: route.ID(v4ID), - Network: m.fakeIPManager.GetFakeIPBlock(), - NetID: route.NetID(v4ID), - Peer: m.pubKey, - NetworkType: route.IPv4Network, - } - v6ID := uuid.NewString() - fakeIPv6Route := &route.Route{ - ID: route.ID(v6ID), - Network: m.fakeIPManager.GetFakeIPv6Block(), - NetID: route.NetID(v6ID), - Peer: m.pubKey, - NetworkType: route.IPv6Network, - } - cr = append(cr, fakeIPRoute, fakeIPv6Route) - m.notifier.SetFakeIPRoutes([]*route.Route{fakeIPRoute, fakeIPv6Route}) - } - - m.notifier.SetInitialClientRoutes(cr, routesForComparison) +func (m *DefaultManager) enableFakeIPRoutes() { + m.fakeIPManager = fakeip.NewManager() + m.notifier.NotifyRouteChange() } func (m *DefaultManager) setupRefCounters(useNoop bool) { @@ -215,7 +185,7 @@ func (m *DefaultManager) setupRefCounters(useNoop bool) { ) } - m.allowedIPsRefCounter = refcounter.New( + m.allowedIPsRefCounter = refcounter.NewAllowedIPs( func(prefix netip.Prefix, peerKey string) (string, error) { // save peerKey to use it in the remove function return peerKey, m.wgInterface.AddAllowedIP(peerKey, prefix) @@ -232,6 +202,30 @@ func (m *DefaultManager) setupRefCounters(useNoop bool) { ) } +// ReconcilePeerAllowedIPs re-applies every routed allowed IP currently tracked for the peer +// onto the WireGuard device. The allowed-IP refcounter only calls its AddFunc (which pushes to +// the device) on a prefix's 0->1 transition, so a peer whose device entry was rebuilt without a +// matching refcounter change — e.g. a lazy connection cycling through idle->wake, which recreates +// the WireGuard peer with the overlay /32 only — ends up missing routed prefixes the refcounter +// still considers installed, and nothing retries. Calling this when the peer's WireGuard entry is +// (re)created restores convergence. It is add-only and idempotent: AddAllowedIP is update-only, so +// prefixes are re-added to an existing peer and an absent peer is left untouched. +func (m *DefaultManager) ReconcilePeerAllowedIPs(peerKey string) error { + if m.allowedIPsRefCounter == nil { + return nil + } + + return m.allowedIPsRefCounter.ReapplyMatching( + func(out string) bool { return out == peerKey }, + func(prefix netip.Prefix) error { + if err := m.wgInterface.AddAllowedIP(peerKey, prefix); err != nil { + return fmt.Errorf("add allowed IP %s for peer %s: %w", prefix, peerKey, err) + } + return nil + }, + ) +} + // Init sets up the routing func (m *DefaultManager) Init() error { m.routeSelector = m.initSelector() @@ -435,6 +429,9 @@ func (m *DefaultManager) UpdateRoutes( var merr *multierror.Error if !m.disableClientRoutes { + if runtime.GOOS == "android" && useNewDNSRoute && m.fakeIPManager == nil { + m.enableFakeIPRoutes() + } // Update route selector based on management server's isSelected status m.updateRouteSelectorFromManagement(clientRoutes) @@ -471,9 +468,18 @@ func (m *DefaultManager) SetRouteChangeListener(listener listener.NetworkChangeL m.notifier.SetListener(listener) } -// InitialRouteRange return the list of initial routes. It used by mobile systems -func (m *DefaultManager) InitialRouteRange() []string { - return m.notifier.GetInitialRouteRanges() +// CurrentRouteRange returns the current TUN route list. It is used by mobile systems +func (m *DefaultManager) CurrentRouteRange() []string { + m.mux.Lock() + defer m.mux.Unlock() + + nets := m.overlayNetworks() + if !m.disableClientRoutes { + nets = append(nets, m.clientRouteRange()...) + } + + sort.Strings(nets) + return slices.Compact(nets) } // GetRouteSelector returns the route selector @@ -671,16 +677,6 @@ func (m *DefaultManager) ClassifyRoutes(newRoutes []*route.Route) (map[route.ID] return newServerRoutesMap, newClientRoutesIDMap } -func (m *DefaultManager) initialClientRoutes(initialRoutes []*route.Route) []*route.Route { - _, crMap := m.ClassifyRoutes(initialRoutes) - rs := make([]*route.Route, 0, len(crMap)) - for _, routes := range crMap { - rs = append(rs, routes...) - } - - return rs -} - func isRouteSupported(route *route.Route) bool { if netstack.IsEnabled() || !nbnet.CustomRoutingDisabled() || route.IsDynamic() { return true @@ -775,7 +771,7 @@ func (m *DefaultManager) collectExitNodeInfo(clientRoutes route.HAMap) exitNodeI var info exitNodeInfo for haID, routes := range clientRoutes { - if !m.isExitNodeRoute(routes) { + if !isExitNodeRoutes(routes) { continue } @@ -795,13 +791,6 @@ func (m *DefaultManager) collectExitNodeInfo(clientRoutes route.HAMap) exitNodeI return info } -func (m *DefaultManager) isExitNodeRoute(routes []*route.Route) bool { - if len(routes) == 0 { - return false - } - return route.IsV4DefaultRoute(routes[0].Network) || route.IsV6DefaultRoute(routes[0].Network) -} - func (m *DefaultManager) categorizeUserSelection(netID route.NetID, info *exitNodeInfo) { if m.routeSelector.IsSelected(netID) { info.userSelected = append(info.userSelected, netID) @@ -854,6 +843,42 @@ func (m *DefaultManager) logExitNodeUpdate(info exitNodeInfo, preferred route.Ne len(info.allIDs), preferred, len(info.userSelected), len(info.userDeselected), len(info.selectedByManagement)) } +// overlayNetworks returns the v4 and v6 overlay networks of the WireGuard interface, each only when it is set. +func (m *DefaultManager) overlayNetworks() []string { + if m.wgInterface == nil { + return nil + } + + addr := m.wgInterface.Address() + var nets []string + if addr.Network.IsValid() { + nets = append(nets, addr.Network.String()) + } + if addr.IPv6Net.IsValid() { + nets = append(nets, addr.IPv6Net.String()) + } + return nets +} + +// clientRouteRange returns the static client route networks of the selected exit nodes together with the fake IP blocks. +func (m *DefaultManager) clientRouteRange() []string { + filtered := m.routeSelector.FilterSelectedExitNodes(m.clientRoutes) + var nets []string + for _, routes := range filtered { + for _, r := range routes { + if r.IsDynamic() { + continue + } + nets = append(nets, r.NetString()) + } + } + + if m.fakeIPManager != nil { + nets = append(nets, m.fakeIPManager.GetFakeIPBlock().String(), m.fakeIPManager.GetFakeIPv6Block().String()) + } + return nets +} + // minNetID returns the lexicographically smallest NetID, for a deterministic // default pick that stays stable across restarts. func minNetID(ids []route.NetID) route.NetID { diff --git a/client/internal/routemanager/mock.go b/client/internal/routemanager/mock.go index 937314995..2a8398b95 100644 --- a/client/internal/routemanager/mock.go +++ b/client/internal/routemanager/mock.go @@ -16,6 +16,8 @@ type MockManager struct { ClassifyRoutesFunc func(routes []*route.Route) (map[route.ID]*route.Route, route.HAMap) UpdateRoutesFunc func(updateSerial uint64, serverRoutes map[route.ID]*route.Route, clientRoutes route.HAMap, useNewDNSRoute bool) error TriggerSelectionFunc func(haMap route.HAMap) + SelectRoutesFunc func(ids []route.NetID, appendRoute bool) error + DeselectRoutesFunc func(ids []route.NetID) error GetRouteSelectorFunc func() *routeselector.RouteSelector GetClientRoutesFunc func() route.HAMap GetSelectedClientRoutesFunc func() route.HAMap @@ -28,8 +30,8 @@ func (m *MockManager) Init() error { return nil } -// InitialRouteRange mock implementation of InitialRouteRange from Manager interface -func (m *MockManager) InitialRouteRange() []string { +// CurrentRouteRange mock implementation of CurrentRouteRange from Manager interface +func (m *MockManager) CurrentRouteRange() []string { return nil } @@ -55,6 +57,30 @@ func (m *MockManager) TriggerSelection(networks route.HAMap) { } } +// SelectRoutes mock implementation of SelectRoutes from Manager interface +func (m *MockManager) SelectRoutes(ids []route.NetID, appendRoute bool) error { + if m.SelectRoutesFunc != nil { + return m.SelectRoutesFunc(ids, appendRoute) + } + return nil +} + +// DeselectRoutes mock implementation of DeselectRoutes from Manager interface +func (m *MockManager) DeselectRoutes(ids []route.NetID) error { + if m.DeselectRoutesFunc != nil { + return m.DeselectRoutesFunc(ids) + } + return nil +} + +// SelectAllRoutes mock implementation of SelectAllRoutes from Manager interface +func (m *MockManager) SelectAllRoutes() { +} + +// DeselectAllRoutes mock implementation of DeselectAllRoutes from Manager interface +func (m *MockManager) DeselectAllRoutes() { +} + // GetRouteSelector mock implementation of GetRouteSelector from Manager interface func (m *MockManager) GetRouteSelector() *routeselector.RouteSelector { if m.GetRouteSelectorFunc != nil { @@ -112,6 +138,11 @@ func (m *MockManager) SetFirewall(firewall.Manager) error { func (m *MockManager) SetDNSForwarderPort(port uint16) { } +// ReconcilePeerAllowedIPs mock implementation of ReconcilePeerAllowedIPs from Manager interface +func (m *MockManager) ReconcilePeerAllowedIPs(peerKey string) error { + return nil +} + // Stop mock implementation of Stop from Manager interface func (m *MockManager) Stop(stateManager *statemanager.Manager) { if m.StopFunc != nil { diff --git a/client/internal/routemanager/notifier/notifier_android.go b/client/internal/routemanager/notifier/notifier_android.go index 49300dbb2..24cbb94db 100644 --- a/client/internal/routemanager/notifier/notifier_android.go +++ b/client/internal/routemanager/notifier/notifier_android.go @@ -4,9 +4,6 @@ package notifier import ( "net/netip" - "slices" - "sort" - "strings" "sync" "github.com/netbirdio/netbird/client/internal/listener" @@ -14,12 +11,15 @@ import ( ) type Notifier struct { - initialRoutes []*route.Route - currentRoutes []*route.Route - fakeIPRoutes []*route.Route + mu sync.Mutex - listener listener.NetworkChangeListener - listenerMux sync.Mutex + // currentRoutes is the last announced route set. It exists only to + // suppress noise: without it every network map sync would trigger the + // Java side, even when the routes did not change. The actual TUN route + // state is owned by the route manager and pulled from there. + currentRoutes []*route.Route + + listener listener.NetworkChangeListener } func NewNotifier() *Notifier { @@ -27,20 +27,15 @@ func NewNotifier() *Notifier { } func (n *Notifier) SetListener(listener listener.NetworkChangeListener) { - n.listenerMux.Lock() - defer n.listenerMux.Unlock() + n.mu.Lock() + defer n.mu.Unlock() n.listener = listener } -// SetInitialClientRoutes stores the initial route sets for TUN configuration. -func (n *Notifier) SetInitialClientRoutes(initialRoutes []*route.Route, routesForComparison []*route.Route) { - n.initialRoutes = filterStatic(initialRoutes) - n.currentRoutes = filterStatic(routesForComparison) -} - -// SetFakeIPRoutes stores the fake IP routes to be included in every TUN rebuild. -func (n *Notifier) SetFakeIPRoutes(routes []*route.Route) { - n.fakeIPRoutes = routes +func (n *Notifier) NotifyRouteChange() { + n.mu.Lock() + defer n.mu.Unlock() + n.notifyLocked() } func (n *Notifier) OnNewRoutes(idMap route.HAMap) { @@ -54,70 +49,25 @@ func (n *Notifier) OnNewRoutes(idMap route.HAMap) { } } - if !n.hasRouteDiff(n.currentRoutes, newRoutes) { + n.mu.Lock() + defer n.mu.Unlock() + if !hasRouteDiff(n.currentRoutes, newRoutes) { return } n.currentRoutes = newRoutes - n.notify() + n.notifyLocked() } func (n *Notifier) OnNewPrefixes([]netip.Prefix) { // Not used on Android } -func (n *Notifier) notify() { - n.listenerMux.Lock() - defer n.listenerMux.Unlock() +func (n *Notifier) notifyLocked() { if n.listener == nil { return } - - allRoutes := slices.Clone(n.currentRoutes) - allRoutes = append(allRoutes, n.fakeIPRoutes...) - - routeStrings := n.routesToStrings(allRoutes) - sort.Strings(routeStrings) - go func(l listener.NetworkChangeListener) { - l.OnNetworkChanged(strings.Join(routeStrings, ",")) - }(n.listener) -} - -func filterStatic(routes []*route.Route) []*route.Route { - out := make([]*route.Route, 0, len(routes)) - for _, r := range routes { - if !r.IsDynamic() { - out = append(out, r) - } - } - return out -} - -func (n *Notifier) routesToStrings(routes []*route.Route) []string { - nets := make([]string, 0, len(routes)) - for _, r := range routes { - nets = append(nets, r.NetString()) - } - return nets -} - -func (n *Notifier) hasRouteDiff(a []*route.Route, b []*route.Route) bool { - slices.SortFunc(a, func(x, y *route.Route) int { - return strings.Compare(x.NetString(), y.NetString()) - }) - slices.SortFunc(b, func(x, y *route.Route) int { - return strings.Compare(x.NetString(), y.NetString()) - }) - - return !slices.EqualFunc(a, b, func(x, y *route.Route) bool { - return x.NetString() == y.NetString() - }) -} - -func (n *Notifier) GetInitialRouteRanges() []string { - initialStrings := n.routesToStrings(n.initialRoutes) - sort.Strings(initialStrings) - return initialStrings + n.listener.OnNetworkChanged("") } func (n *Notifier) Close() { diff --git a/client/internal/routemanager/notifier/notifier_ios.go b/client/internal/routemanager/notifier/notifier_ios.go index d0888f3a1..d663dd471 100644 --- a/client/internal/routemanager/notifier/notifier_ios.go +++ b/client/internal/routemanager/notifier/notifier_ios.go @@ -3,7 +3,6 @@ package notifier import ( - "container/list" "net/netip" "slices" "sort" @@ -16,20 +15,12 @@ import ( type Notifier struct { mu sync.Mutex - cond *sync.Cond currentPrefixes []string listener listener.NetworkChangeListener - queue *list.List - closed bool } func NewNotifier() *Notifier { - n := &Notifier{ - queue: list.New(), - } - n.cond = sync.NewCond(&n.mu) - go n.deliverLoop() - return n + return &Notifier{} } func (n *Notifier) SetListener(listener listener.NetworkChangeListener) { @@ -38,11 +29,7 @@ func (n *Notifier) SetListener(listener listener.NetworkChangeListener) { n.listener = listener } -func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) { - // iOS doesn't care about initial routes -} - -func (n *Notifier) SetFakeIPRoutes([]*route.Route) { +func (n *Notifier) NotifyRouteChange() { // Not used on iOS } @@ -59,44 +46,19 @@ func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) { sort.Strings(newNets) n.mu.Lock() + defer n.mu.Unlock() if slices.Equal(n.currentPrefixes, newNets) { - n.mu.Unlock() return } n.currentPrefixes = newNets - routes := strings.Join(n.currentPrefixes, ",") - n.queue.PushBack(routes) - n.cond.Signal() - n.mu.Unlock() + if n.listener != nil { + n.listener.OnNetworkChanged(strings.Join(n.currentPrefixes, ",")) + } } func (n *Notifier) Close() { - n.mu.Lock() - n.closed = true - n.cond.Signal() - n.mu.Unlock() } func (n *Notifier) GetInitialRouteRanges() []string { return nil } - -func (n *Notifier) deliverLoop() { - for { - n.mu.Lock() - for n.queue.Len() == 0 && !n.closed { - n.cond.Wait() - } - if n.closed && n.queue.Len() == 0 { - n.mu.Unlock() - return - } - routes := n.queue.Remove(n.queue.Front()).(string) - l := n.listener - n.mu.Unlock() - - if l != nil { - l.OnNetworkChanged(routes) - } - } -} diff --git a/client/internal/routemanager/notifier/notifier_other.go b/client/internal/routemanager/notifier/notifier_other.go index 71b1096c2..fe48e07b3 100644 --- a/client/internal/routemanager/notifier/notifier_other.go +++ b/client/internal/routemanager/notifier/notifier_other.go @@ -19,11 +19,7 @@ func (n *Notifier) SetListener(listener listener.NetworkChangeListener) { // Not used on non-mobile platforms } -func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) { - // Not used on non-mobile platforms -} - -func (n *Notifier) SetFakeIPRoutes([]*route.Route) { +func (n *Notifier) NotifyRouteChange() { // Not used on non-mobile platforms } @@ -35,10 +31,6 @@ func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) { // Not used on non-mobile platforms } -func (n *Notifier) GetInitialRouteRanges() []string { - return []string{} -} - func (n *Notifier) Close() { // unused } diff --git a/client/internal/routemanager/notifier/route_diff.go b/client/internal/routemanager/notifier/route_diff.go new file mode 100644 index 000000000..52abddf36 --- /dev/null +++ b/client/internal/routemanager/notifier/route_diff.go @@ -0,0 +1,27 @@ +package notifier + +import ( + "slices" + "sort" + + "github.com/netbirdio/netbird/route" +) + +// routePrefixes returns the distinct prefixes a route set covers, sorted. +// Duplicates are dropped deliberately: an HA group hands us one route per +// peer serving the same prefix, and the platform is given the prefix, not the +// candidates. Counting them would report a change every time a peer joins or +// leaves a group, and on Android each report renews the TUN. +func routePrefixes(routes []*route.Route) []string { + nets := make([]string, 0, len(routes)) + for _, r := range routes { + nets = append(nets, r.NetString()) + } + sort.Strings(nets) + return slices.Compact(nets) +} + +// hasRouteDiff reports whether the prefixes the two route sets cover differ. +func hasRouteDiff(a []*route.Route, b []*route.Route) bool { + return !slices.Equal(routePrefixes(a), routePrefixes(b)) +} diff --git a/client/internal/routemanager/notifier/route_diff_test.go b/client/internal/routemanager/notifier/route_diff_test.go new file mode 100644 index 000000000..80df69d9d --- /dev/null +++ b/client/internal/routemanager/notifier/route_diff_test.go @@ -0,0 +1,88 @@ +package notifier + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/route" +) + +func routeFor(id route.ID, prefix string) *route.Route { + return &route.Route{ + ID: id, + NetID: "net", + Network: netip.MustParsePrefix(prefix), + } +} + +// TestHasRouteDiff_IgnoresHACandidateCount is the reason the comparison +// deduplicates. Every notification renews the TUN, and a renewed TUN +// invalidates the sockets the embedded servers are listening on, so a peer +// joining or leaving an HA group must not count as a route change when the +// prefixes the TUN carries are identical. +func TestHasRouteDiff_IgnoresHACandidateCount(t *testing.T) { + onePeer := []*route.Route{routeFor("a", "10.0.0.0/24")} + twoPeers := []*route.Route{ + routeFor("a", "10.0.0.0/24"), + routeFor("b", "10.0.0.0/24"), + } + + assert.False(t, hasRouteDiff(onePeer, twoPeers), + "a second peer serving the same prefix is not a route change") + assert.False(t, hasRouteDiff(twoPeers, onePeer), + "losing one of two peers serving the same prefix is not a route change") +} + +func TestHasRouteDiff_ReportsRealChanges(t *testing.T) { + tests := []struct { + name string + a []*route.Route + b []*route.Route + want bool + }{ + { + name: "added prefix", + a: []*route.Route{routeFor("a", "10.0.0.0/24")}, + b: []*route.Route{routeFor("a", "10.0.0.0/24"), routeFor("b", "10.0.1.0/24")}, + want: true, + }, + { + name: "removed prefix", + a: []*route.Route{routeFor("a", "10.0.0.0/24"), routeFor("b", "10.0.1.0/24")}, + b: []*route.Route{routeFor("a", "10.0.0.0/24")}, + want: true, + }, + { + name: "replaced prefix", + a: []*route.Route{routeFor("a", "10.0.0.0/24")}, + b: []*route.Route{routeFor("a", "10.0.1.0/24")}, + want: true, + }, + { + name: "same prefix, different order", + a: []*route.Route{routeFor("a", "10.0.1.0/24"), routeFor("b", "10.0.0.0/24")}, + b: []*route.Route{routeFor("b", "10.0.0.0/24"), routeFor("a", "10.0.1.0/24")}, + want: false, + }, + { + name: "all routes gone", + a: []*route.Route{routeFor("a", "10.0.0.0/24")}, + b: nil, + want: true, + }, + { + name: "both empty", + a: nil, + b: nil, + want: false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, hasRouteDiff(tc.a, tc.b), + "route diff for %s", tc.name) + }) + } +} diff --git a/client/internal/routemanager/reconcile_test.go b/client/internal/routemanager/reconcile_test.go new file mode 100644 index 000000000..bc9693229 --- /dev/null +++ b/client/internal/routemanager/reconcile_test.go @@ -0,0 +1,91 @@ +//go:build !windows + +package routemanager + +import ( + "net" + "net/netip" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.zx2c4.com/wireguard/tun/netstack" + + "github.com/netbirdio/netbird/client/iface/device" + "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/internal/routemanager/refcounter" +) + +// reconcileWGMock is a minimal iface.WGIface that records AddAllowedIP calls and reports the +// configured address; every other method is an inert stub because the tests exercise none of them. +type reconcileWGMock struct { + mu sync.Mutex + adds map[string][]netip.Prefix + addr wgaddr.Address +} + +func (m *reconcileWGMock) AddAllowedIP(peerKey string, allowedIP netip.Prefix) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.adds == nil { + m.adds = map[string][]netip.Prefix{} + } + m.adds[peerKey] = append(m.adds[peerKey], allowedIP) + return nil +} + +func (m *reconcileWGMock) added(peerKey string) []netip.Prefix { + m.mu.Lock() + defer m.mu.Unlock() + return m.adds[peerKey] +} + +func (m *reconcileWGMock) RemoveAllowedIP(string, netip.Prefix) error { return nil } +func (m *reconcileWGMock) Name() string { return "utun-test" } +func (m *reconcileWGMock) Address() wgaddr.Address { return m.addr } +func (m *reconcileWGMock) ToInterface() *net.Interface { return nil } +func (m *reconcileWGMock) IsUserspaceBind() bool { return false } +func (m *reconcileWGMock) GetFilter() device.PacketFilter { return nil } +func (m *reconcileWGMock) GetDevice() *device.FilteredDevice { return nil } +func (m *reconcileWGMock) GetNet() *netstack.Net { return nil } + +// TestReconcilePeerAllowedIPs verifies the declarative reconcile re-applies every routed prefix +// tracked for the peer (self-heal, independent of refcount level) and stays scoped to that peer. +func TestReconcilePeerAllowedIPs(t *testing.T) { + wg := &reconcileWGMock{} + m := &DefaultManager{wgInterface: wg} + m.allowedIPsRefCounter = refcounter.NewAllowedIPs( + func(_ netip.Prefix, peerKey string) (string, error) { return peerKey, nil }, + func(netip.Prefix, string) error { return nil }, + ) + + peerA1 := netip.MustParsePrefix("10.0.0.0/24") + peerA2 := netip.MustParsePrefix("10.1.0.0/24") + peerB1 := netip.MustParsePrefix("10.2.0.0/24") + + for prefix, peer := range map[netip.Prefix]string{peerA1: "peerA", peerA2: "peerA", peerB1: "peerB"} { + _, err := m.allowedIPsRefCounter.Increment(prefix, peer) + require.NoError(t, err) + } + // Extra reference: reconcile must still re-apply the prefix even though its refcount never + // hit 0 again (the exact case the plain incremental path skips). + _, err := m.allowedIPsRefCounter.Increment(peerA1, "peerA") + require.NoError(t, err) + + require.NoError(t, m.ReconcilePeerAllowedIPs("peerA")) + + assert.ElementsMatch(t, []netip.Prefix{peerA1, peerA2}, wg.added("peerA"), + "reconcile must re-apply all routed prefixes of the peer") + assert.Empty(t, wg.added("peerB"), "reconcile must not touch another peer's prefixes") +} + +// TestReconcilePeerAllowedIPsNoCounter verifies reconcile is a safe no-op before the refcounter is +// set up. +func TestReconcilePeerAllowedIPsNoCounter(t *testing.T) { + wg := &reconcileWGMock{} + m := &DefaultManager{wgInterface: wg} + + require.NoError(t, m.ReconcilePeerAllowedIPs("peerA")) + assert.Empty(t, wg.added("peerA")) +} diff --git a/client/internal/routemanager/refcounter/allowedips.go b/client/internal/routemanager/refcounter/allowedips.go new file mode 100644 index 000000000..6d682e8a9 --- /dev/null +++ b/client/internal/routemanager/refcounter/allowedips.go @@ -0,0 +1,206 @@ +package refcounter + +import ( + "errors" + "fmt" + "net/netip" + "sort" + "sync" + + "github.com/hashicorp/go-multierror" + + nberrors "github.com/netbirdio/netbird/client/errors" +) + +// allowedIPsEntry holds the per-peer reference counts for a single prefix and which peer is +// currently installed in WireGuard. WireGuard allows a prefix on exactly one peer, so at most +// one peer is active at a time even when several peers reference the prefix. +type allowedIPsEntry struct { + // peers maps a peerKey to the number of references holding the prefix for that peer. + peers map[string]int + // active is the peerKey currently installed in WireGuard for this prefix ("" if none). + active string + // total is the sum of all per-peer reference counts (kept in sync with peers). + total int +} + +// AllowedIPsRefCounter is a peer-aware reference counter for WireGuard AllowedIPs. +// +// The generic Counter keys only by prefix and remembers a single Out value set by the first +// caller, which it never changes. That is wrong for AllowedIPs: two independent watchers (or +// multiple resolved domains) can reference the same prefix through different peers, and when the +// peer currently installed in WireGuard releases its last reference the prefix must be handed over +// to a surviving peer instead of being left pointing at the released one. +// +// It calls add/remove (which program WireGuard) only on the transitions that matter: +// - add on the first reference for a prefix, or when swapping the active peer; +// - remove on the last reference for a prefix, or on the old peer during a swap. +type AllowedIPsRefCounter struct { + mu sync.Mutex + entries map[netip.Prefix]*allowedIPsEntry + add AddFunc[netip.Prefix, string, string] + remove RemoveFunc[netip.Prefix, string] +} + +// NewAllowedIPs creates a new peer-aware AllowedIPs reference counter. +// add programs a prefix on a peer in WireGuard and returns the peerKey to store as the active peer. +// remove unprograms the prefix from the given peer. +func NewAllowedIPs(add AddFunc[netip.Prefix, string, string], remove RemoveFunc[netip.Prefix, string]) *AllowedIPsRefCounter { + return &AllowedIPsRefCounter{ + entries: map[netip.Prefix]*allowedIPsEntry{}, + add: add, + remove: remove, + } +} + +// Increment adds a reference to prefix for peerKey. WireGuard is programmed only for the first +// reference to a prefix; while a different peer is already installed the prefix is left with it +// (first peer wins, HA at the WireGuard layer is not possible) and only the reference count is kept. +func (rm *AllowedIPsRefCounter) Increment(prefix netip.Prefix, peerKey string) (Ref[string], error) { + rm.mu.Lock() + defer rm.mu.Unlock() + + e, ok := rm.entries[prefix] + if !ok { + e = &allowedIPsEntry{peers: map[string]int{}} + rm.entries[prefix] = e + } + + logCallerF("Increasing allowed IP ref count for prefix %v peer %s [peer %d -> %d, total %d -> %d, active %q]", + prefix, peerKey, e.peers[peerKey], e.peers[peerKey]+1, e.total, e.total+1, e.active) + + // Program WireGuard only when nothing is installed yet for this prefix. + if e.active == "" { + out, err := rm.add(prefix, peerKey) + if errors.Is(err, ErrIgnore) { + if e.total == 0 { + delete(rm.entries, prefix) + } + return Ref[string]{Count: e.total, Out: e.active}, nil + } + if err != nil { + if e.total == 0 { + delete(rm.entries, prefix) + } + return Ref[string]{}, fmt.Errorf("failed to add allowed IP %v for peer %s: %w", prefix, peerKey, err) + } + e.active = out + } + + e.peers[peerKey]++ + e.total++ + + return Ref[string]{Count: e.total, Out: e.active}, nil +} + +// Decrement removes a reference to prefix for peerKey. When the peer currently installed in +// WireGuard releases its last reference, the prefix is swapped to a surviving peer if one exists, +// otherwise it is removed from WireGuard. +func (rm *AllowedIPsRefCounter) Decrement(prefix netip.Prefix, peerKey string) (Ref[string], error) { + rm.mu.Lock() + defer rm.mu.Unlock() + + e, ok := rm.entries[prefix] + if !ok { + logCallerF("No allowed IP reference found for prefix %v", prefix) + return Ref[string]{}, nil + } + + if e.peers[peerKey] > 0 { + logCallerF("Decreasing allowed IP ref count for prefix %v peer %s [peer %d -> %d, total %d -> %d, active %q]", + prefix, peerKey, e.peers[peerKey], e.peers[peerKey]-1, e.total, e.total-1, e.active) + e.peers[peerKey]-- + e.total-- + if e.peers[peerKey] == 0 { + delete(e.peers, peerKey) + } + } else { + logCallerF("No allowed IP reference found for prefix %v peer %s", prefix, peerKey) + } + + // If the peer currently installed in WireGuard still holds references, nothing to reprogram. + // Keying the check on the active peer (not the one just released) makes this self-healing: + // a prior swap whose remove/add failed leaves e.active pointing at a peer with no references, + // and this retries the hand-off on the next Decrement instead of getting stuck. + if e.active != "" && e.peers[e.active] > 0 { + return Ref[string]{Count: e.total, Out: e.active}, nil + } + + // Detach the stale/gone active peer from WireGuard before reprogramming. + if e.active != "" { + if err := rm.remove(prefix, e.active); err != nil { + return Ref[string]{Count: e.total, Out: e.active}, fmt.Errorf("remove allowed IP %v for peer %s: %w", prefix, e.active, err) + } + e.active = "" + } + + // Hand the prefix over to a surviving peer, or drop the entry when none remain. + if survivor, ok := pickSurvivor(e.peers); ok { + out, err := rm.add(prefix, survivor) + if err != nil { + return Ref[string]{Count: e.total, Out: ""}, fmt.Errorf("swap allowed IP %v to peer %s: %w", prefix, survivor, err) + } + e.active = out + return Ref[string]{Count: e.total, Out: e.active}, nil + } + + delete(rm.entries, prefix) + return Ref[string]{Count: 0, Out: ""}, nil +} + +// Flush removes all prefixes from WireGuard and clears the counter. +func (rm *AllowedIPsRefCounter) Flush() error { + rm.mu.Lock() + defer rm.mu.Unlock() + + var merr *multierror.Error + for prefix, e := range rm.entries { + if e.active == "" { + continue + } + logCallerF("Flushing allowed IP for prefix %v peer %s", prefix, e.active) + if err := rm.remove(prefix, e.active); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %v for peer %s: %w", prefix, e.active, err)) + } + } + + clear(rm.entries) + + return nberrors.FormatErrorOrNil(merr) +} + +// ReapplyMatching calls apply for every prefix whose currently installed (active) peer satisfies +// pred, holding the lock for the whole pass. It is used to re-push allowed IPs onto a peer whose +// WireGuard entry was rebuilt (e.g. a lazy connection cycling idle->wake) without a matching +// refcounter change, which would otherwise leave the prefix installed in the counter but missing +// on the device. Only the active peer is considered — a prefix that lost its installed peer to a +// failed swap is skipped here and reconciled by the next Increment/Decrement. +func (rm *AllowedIPsRefCounter) ReapplyMatching(pred func(out string) bool, apply func(key netip.Prefix) error) error { + rm.mu.Lock() + defer rm.mu.Unlock() + + var merr *multierror.Error + for prefix, e := range rm.entries { + if e.active != "" && pred(e.active) { + if err := apply(prefix); err != nil { + merr = multierror.Append(merr, err) + } + } + } + return nberrors.FormatErrorOrNil(merr) +} + +// pickSurvivor deterministically selects a peer still referencing the prefix. WireGuard cannot do +// multipath for a single prefix, so any surviving peer is a valid winner; the choice is made stable +// (lowest peerKey) for predictable behavior and testability. +func pickSurvivor(peers map[string]int) (string, bool) { + if len(peers) == 0 { + return "", false + } + keys := make([]string, 0, len(peers)) + for k := range peers { + keys = append(keys, k) + } + sort.Strings(keys) + return keys[0], true +} diff --git a/client/internal/routemanager/refcounter/allowedips_test.go b/client/internal/routemanager/refcounter/allowedips_test.go new file mode 100644 index 000000000..835142083 --- /dev/null +++ b/client/internal/routemanager/refcounter/allowedips_test.go @@ -0,0 +1,241 @@ +package refcounter + +import ( + "errors" + "net/netip" + "testing" +) + +// fakeWG models WireGuard's cryptokey routing: a prefix can be installed on exactly one peer. +// failAdd/failRemove make the next add/remove fail once, to exercise the self-healing error paths. +type fakeWG struct { + installed map[netip.Prefix]string + adds int + removes int + failAdd bool + failRemove bool +} + +func newFakeWG() *fakeWG { + return &fakeWG{installed: map[netip.Prefix]string{}} +} + +func (f *fakeWG) counter() *AllowedIPsRefCounter { + return NewAllowedIPs( + func(prefix netip.Prefix, peerKey string) (string, error) { + if f.failAdd { + f.failAdd = false + return "", errors.New("add failed") + } + f.adds++ + f.installed[prefix] = peerKey + return peerKey, nil + }, + func(prefix netip.Prefix, peerKey string) error { + if f.failRemove { + f.failRemove = false + return errors.New("remove failed") + } + f.removes++ + // only clear if this peer is the one installed, mirroring wg semantics + if f.installed[prefix] == peerKey { + delete(f.installed, prefix) + } + return nil + }, + ) +} + +func mustPrefix(t *testing.T, s string) netip.Prefix { + t.Helper() + p, err := netip.ParsePrefix(s) + if err != nil { + t.Fatalf("parse prefix %q: %v", s, err) + } + return p +} + +func mustIncrement(t *testing.T, c *AllowedIPsRefCounter, p netip.Prefix, peer string) Ref[string] { + t.Helper() + ref, err := c.Increment(p, peer) + if err != nil { + t.Fatalf("Increment(%v, %s): %v", p, peer, err) + } + return ref +} + +func mustDecrement(t *testing.T, c *AllowedIPsRefCounter, p netip.Prefix, peer string) Ref[string] { + t.Helper() + ref, err := c.Decrement(p, peer) + if err != nil { + t.Fatalf("Decrement(%v, %s): %v", p, peer, err) + } + return ref +} + +// TestAllowedIPs_SwapOnActivePeerRemoval reproduces the reported bug: two networks with the same +// prefix routed by different peers. Removing the network whose peer is installed must hand the +// prefix over to the surviving peer instead of leaving it on the removed one. +func TestAllowedIPs_SwapOnActivePeerRemoval(t *testing.T) { + f := newFakeWG() + c := f.counter() + p := mustPrefix(t, "10.44.8.0/24") + + mustIncrement(t, c, p, "peerA") + mustIncrement(t, c, p, "peerB") + // First peer wins while both are present. + if got := f.installed[p]; got != "peerA" { + t.Fatalf("expected peerA installed, got %q", got) + } + + // Remove the active peer's network -> must swap to peerB. + mustDecrement(t, c, p, "peerA") + if got := f.installed[p]; got != "peerB" { + t.Fatalf("BUG: prefix stuck on removed peer, want peerB got %q", got) + } + + // Remove the last one -> prefix gone. + mustDecrement(t, c, p, "peerB") + if _, ok := f.installed[p]; ok { + t.Fatalf("expected prefix removed, still installed on %q", f.installed[p]) + } +} + +// TestAllowedIPs_RemoveNonActivePeer removing a non-installed peer must not touch WireGuard. +func TestAllowedIPs_RemoveNonActivePeer(t *testing.T) { + f := newFakeWG() + c := f.counter() + p := mustPrefix(t, "10.44.8.0/24") + + mustIncrement(t, c, p, "peerA") + mustIncrement(t, c, p, "peerB") + removesBefore := f.removes + + mustDecrement(t, c, p, "peerB") + if f.installed[p] != "peerA" { + t.Fatalf("active peer must stay peerA, got %q", f.installed[p]) + } + if f.removes != removesBefore { + t.Fatalf("removing a non-active peer must not call wg remove") + } +} + +// TestAllowedIPs_SamePeerMultipleRefs two references via the same peer must keep the prefix until +// the last reference is released (the reason the per-peer count must be an int, not a set). +func TestAllowedIPs_SamePeerMultipleRefs(t *testing.T) { + f := newFakeWG() + c := f.counter() + p := mustPrefix(t, "10.44.8.0/24") + + mustIncrement(t, c, p, "peerA") + mustIncrement(t, c, p, "peerA") + if f.adds != 1 { + t.Fatalf("expected a single wg add for the same peer, got %d", f.adds) + } + + mustDecrement(t, c, p, "peerA") + if f.installed[p] != "peerA" { + t.Fatalf("prefix must stay while a reference remains, got %q", f.installed[p]) + } + if f.removes != 0 { + t.Fatalf("no wg remove expected while a reference remains, got %d", f.removes) + } + + mustDecrement(t, c, p, "peerA") + if _, ok := f.installed[p]; ok { + t.Fatalf("prefix must be removed after last reference") + } +} + +// TestAllowedIPs_RefCountAndActive checks the Ref returned to callers (used for the HA-disabled log). +func TestAllowedIPs_RefCountAndActive(t *testing.T) { + f := newFakeWG() + c := f.counter() + p := mustPrefix(t, "10.44.8.0/24") + + ref := mustIncrement(t, c, p, "peerA") + if ref.Count != 1 || ref.Out != "peerA" { + t.Fatalf("want {1, peerA}, got {%d, %q}", ref.Count, ref.Out) + } + ref = mustIncrement(t, c, p, "peerB") + if ref.Count != 2 || ref.Out != "peerA" { + t.Fatalf("want {2, peerA}, got {%d, %q}", ref.Count, ref.Out) + } +} + +// TestAllowedIPs_Flush removes everything installed and clears the counter. +func TestAllowedIPs_Flush(t *testing.T) { + f := newFakeWG() + c := f.counter() + p1 := mustPrefix(t, "10.44.8.0/24") + p2 := mustPrefix(t, "10.44.9.0/24") + + mustIncrement(t, c, p1, "peerA") + mustIncrement(t, c, p2, "peerB") + + if err := c.Flush(); err != nil { + t.Fatal(err) + } + if len(f.installed) != 0 { + t.Fatalf("expected all prefixes removed, got %v", f.installed) + } + // After flush, a fresh increment must add again. + mustIncrement(t, c, p1, "peerC") + if f.installed[p1] != "peerC" { + t.Fatalf("counter not reset after flush") + } +} + +// TestAllowedIPs_SelfHealAfterSwapAddError ensures a failed add during a swap does not permanently +// strand the prefix: the next Decrement (or Increment) must retry and install a surviving peer. +func TestAllowedIPs_SelfHealAfterSwapAddError(t *testing.T) { + f := newFakeWG() + c := f.counter() + p := mustPrefix(t, "10.44.8.0/24") + + mustIncrement(t, c, p, "peerA") + mustIncrement(t, c, p, "peerB") + mustIncrement(t, c, p, "peerC") + + // Removing the active peerA triggers a swap to a survivor; make the add fail once. + f.failAdd = true + if _, err := c.Decrement(p, "peerA"); err == nil { + t.Fatalf("expected error from failed swap add") + } + if _, ok := f.installed[p]; ok { + t.Fatalf("nothing should be installed after a failed swap add, got %q", f.installed[p]) + } + + // A later Decrement of a non-active survivor must retry the hand-off (self-heal), not stay stuck. + ref := mustDecrement(t, c, p, "peerC") + if got := f.installed[p]; got == "" { + t.Fatalf("self-heal failed: prefix left unrouted after add recovered") + } + if ref.Out == "" { + t.Fatalf("expected an active peer after self-heal, got empty") + } +} + +// TestAllowedIPs_SelfHealAfterRemoveError ensures a failed remove during a swap is retried instead +// of leaving e.active stuck on a peer that no longer holds references. +func TestAllowedIPs_SelfHealAfterRemoveError(t *testing.T) { + f := newFakeWG() + c := f.counter() + p := mustPrefix(t, "10.44.8.0/24") + + mustIncrement(t, c, p, "peerA") + mustIncrement(t, c, p, "peerB") + + // Releasing active peerA must detach it (remove) then add peerB; fail the remove once. + f.failRemove = true + if _, err := c.Decrement(p, "peerA"); err == nil { + t.Fatalf("expected error from failed remove") + } + + // Next Decrement of the non-active survivor retries: removes stale peerA, installs peerB. + mustDecrement(t, c, p, "peerB") + // peerB had only one ref, so after retry the prefix is fully released. + if _, ok := f.installed[p]; ok { + t.Fatalf("expected prefix released after self-heal, still on %q", f.installed[p]) + } +} diff --git a/client/internal/routemanager/refcounter/refcounter.go b/client/internal/routemanager/refcounter/refcounter.go index 27a724f50..917120275 100644 --- a/client/internal/routemanager/refcounter/refcounter.go +++ b/client/internal/routemanager/refcounter/refcounter.go @@ -94,6 +94,26 @@ func (rm *Counter[Key, I, O]) Get(key Key) (Ref[O], bool) { return ref, ok } +// ReapplyMatching calls apply for every key whose stored Out satisfies pred, holding the +// counter lock for the whole pass. Running apply under the lock keeps it atomic with respect +// to Increment/Decrement: a prefix dropped to zero is removed from the map (and had its +// RemoveFunc called) before this pass observes it, so a stale key can never be re-applied. +// pred and apply are invoked under the lock, so they must not call back into the counter. +func (rm *Counter[Key, I, O]) ReapplyMatching(pred func(out O) bool, apply func(key Key) error) error { + rm.mu.Lock() + defer rm.mu.Unlock() + + var merr *multierror.Error + for key, ref := range rm.refCountMap { + if pred(ref.Out) { + if err := apply(key); err != nil { + merr = multierror.Append(merr, err) + } + } + } + return nberrors.FormatErrorOrNil(merr) +} + // Increment increments the reference count for the given key. // If this is the first reference to the key, the AddFunc is called. func (rm *Counter[Key, I, O]) Increment(key Key, in I) (Ref[O], error) { diff --git a/client/internal/routemanager/refcounter/refcounter_test.go b/client/internal/routemanager/refcounter/refcounter_test.go new file mode 100644 index 000000000..79a99c388 --- /dev/null +++ b/client/internal/routemanager/refcounter/refcounter_test.go @@ -0,0 +1,47 @@ +package refcounter + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestReapplyMatching verifies ReapplyMatching invokes apply for exactly the keys whose stored +// Out satisfies the predicate (no duplicates for multiply-referenced keys) — the primitive +// ReconcilePeerAllowedIPs relies on to re-apply a single peer's routed prefixes. +func TestReapplyMatching(t *testing.T) { + rc := New[netip.Prefix, string, string]( + func(_ netip.Prefix, peerKey string) (string, error) { return peerKey, nil }, + func(netip.Prefix, string) error { return nil }, + ) + + peerA1 := netip.MustParsePrefix("10.0.0.0/24") + peerA2 := netip.MustParsePrefix("10.1.0.0/24") + peerB1 := netip.MustParsePrefix("10.2.0.0/24") + + for prefix, peer := range map[netip.Prefix]string{peerA1: "peerA", peerA2: "peerA", peerB1: "peerB"} { + _, err := rc.Increment(prefix, peer) + require.NoError(t, err) + } + // a second reference must not make the key applied twice + _, err := rc.Increment(peerA1, "peerA") + require.NoError(t, err) + + var applied []netip.Prefix + err = rc.ReapplyMatching( + func(out string) bool { return out == "peerA" }, + func(key netip.Prefix) error { applied = append(applied, key); return nil }, + ) + require.NoError(t, err) + assert.ElementsMatch(t, []netip.Prefix{peerA1, peerA2}, applied) + + var none []netip.Prefix + err = rc.ReapplyMatching( + func(out string) bool { return out == "missing" }, + func(key netip.Prefix) error { none = append(none, key); return nil }, + ) + require.NoError(t, err) + assert.Empty(t, none) +} diff --git a/client/internal/routemanager/refcounter/types.go b/client/internal/routemanager/refcounter/types.go index aadac3e25..7da0e17e3 100644 --- a/client/internal/routemanager/refcounter/types.go +++ b/client/internal/routemanager/refcounter/types.go @@ -5,5 +5,7 @@ import "net/netip" // RouteRefCounter is a Counter for Route, it doesn't take any input on Increment and doesn't use any output on Decrement type RouteRefCounter = Counter[netip.Prefix, struct{}, struct{}] -// AllowedIPsRefCounter is a Counter for AllowedIPs, it takes a peer key on Increment and passes it back to Decrement -type AllowedIPsRefCounter = Counter[netip.Prefix, string, string] +// AllowedIPsRefCounter tracks WireGuard AllowedIPs per prefix. Unlike the generic Counter it is peer-aware: +// a prefix can be claimed by several peers at once and WireGuard allows a given prefix on exactly one peer, +// so the counter records the per-peer reference count and swaps the installed peer when the active one is released. +// See allowedips.go. diff --git a/client/internal/routemanager/route_range_test.go b/client/internal/routemanager/route_range_test.go new file mode 100644 index 000000000..b51b5747a --- /dev/null +++ b/client/internal/routemanager/route_range_test.go @@ -0,0 +1,95 @@ +//go:build !windows + +package routemanager + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/internal/routeselector" + "github.com/netbirdio/netbird/route" +) + +func TestCurrentRouteRange_OverlayNetworkWithClientRoutesDisabled(t *testing.T) { + m := &DefaultManager{ + wgInterface: &reconcileWGMock{addr: wgaddr.MustParseWGAddress("100.91.96.107/16")}, + disableClientRoutes: true, + } + + assert.Equal(t, []string{"100.91.0.0/16"}, m.CurrentRouteRange(), "overlay network must be routed even when client routes are disabled") +} + +func TestCurrentRouteRange_OverlayNetworksAndClientRoutes(t *testing.T) { + addr := wgaddr.MustParseWGAddress("100.91.96.107/16") + addr.IPv6 = netip.MustParseAddr("fd00:1234::1") + addr.IPv6Net = netip.MustParsePrefix("fd00:1234::/64") + + static := &route.Route{ID: "static", NetID: "lan", Network: netip.MustParsePrefix("192.168.50.0/24"), NetworkType: route.IPv4Network} + dynamic := &route.Route{ID: "dynamic", NetID: "dyn", NetworkType: route.DomainNetwork} + + m := &DefaultManager{ + wgInterface: &reconcileWGMock{addr: addr}, + routeSelector: routeselector.NewRouteSelector(), + clientRoutes: route.HAMap{ + static.GetHAUniqueID(): {static}, + dynamic.GetHAUniqueID(): {dynamic}, + }, + } + + assert.Equal(t, []string{"100.91.0.0/16", "192.168.50.0/24", "fd00:1234::/64"}, m.CurrentRouteRange(), "overlay networks and static client routes must be listed, dynamic routes skipped") +} + +func TestCurrentRouteRange_NoInterfaceAddress(t *testing.T) { + m := &DefaultManager{ + wgInterface: &reconcileWGMock{}, + disableClientRoutes: true, + } + + assert.Empty(t, m.CurrentRouteRange(), "an unset interface address must not produce a route entry") +} + +func TestCurrentRouteRange_IPv6WithoutIPv4Network(t *testing.T) { + addr := wgaddr.Address{ + IPv6: netip.MustParseAddr("fd00:1234::1"), + IPv6Net: netip.MustParsePrefix("fd00:1234::/64"), + } + m := &DefaultManager{ + wgInterface: &reconcileWGMock{addr: addr}, + disableClientRoutes: true, + } + + assert.Equal(t, []string{"fd00:1234::/64"}, m.CurrentRouteRange(), "a v6 overlay network must not depend on a v4 network being set") +} + +func TestCurrentRouteRange_IPv6AddressWithoutNetwork(t *testing.T) { + addr := wgaddr.MustParseWGAddress("100.91.96.107/16") + addr.IPv6 = netip.MustParseAddr("fd00:1234::1") + + m := &DefaultManager{ + wgInterface: &reconcileWGMock{addr: addr}, + disableClientRoutes: true, + } + + assert.Equal(t, []string{"100.91.0.0/16"}, m.CurrentRouteRange(), "a v6 address without a network must not produce a route entry") +} + +func TestCurrentRouteRange_DeduplicatesPrefixes(t *testing.T) { + // Two HA peers serve the same prefix, and a client route announces the overlay network itself. + haPeerA := &route.Route{ID: "ha-a", NetID: "lan", Peer: "peer-a", Network: netip.MustParsePrefix("192.168.50.0/24"), NetworkType: route.IPv4Network} + haPeerB := &route.Route{ID: "ha-b", NetID: "lan", Peer: "peer-b", Network: netip.MustParsePrefix("192.168.50.0/24"), NetworkType: route.IPv4Network} + overlay := &route.Route{ID: "overlay", NetID: "overlay", Network: netip.MustParsePrefix("100.91.0.0/16"), NetworkType: route.IPv4Network} + + m := &DefaultManager{ + wgInterface: &reconcileWGMock{addr: wgaddr.MustParseWGAddress("100.91.96.107/16")}, + routeSelector: routeselector.NewRouteSelector(), + clientRoutes: route.HAMap{ + haPeerA.GetHAUniqueID(): {haPeerA, haPeerB}, + overlay.GetHAUniqueID(): {overlay}, + }, + } + + assert.Equal(t, []string{"100.91.0.0/16", "192.168.50.0/24"}, m.CurrentRouteRange(), "every prefix must be listed once regardless of how many routes carry it") +} diff --git a/client/internal/routemanager/selection.go b/client/internal/routemanager/selection.go new file mode 100644 index 000000000..b81d51b67 --- /dev/null +++ b/client/internal/routemanager/selection.go @@ -0,0 +1,145 @@ +package routemanager + +import ( + "fmt" + "slices" + + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + "golang.org/x/exp/maps" + + nberrors "github.com/netbirdio/netbird/client/errors" + "github.com/netbirdio/netbird/route" +) + +// SelectRoutes selects the routes with the given network IDs and applies the +// new selection. V4/v6 exit-node pairs are expanded automatically. Exit nodes +// are mutually exclusive: if the selection activates an exit node, every other +// available exit node is deselected so two can't be active at once. With +// appendRoute=false the previous selection is replaced instead of extended. +// A partial failure (e.g. an unknown ID mixed with valid ones) still applies +// the valid IDs to the routing table; the unknown ones are reported in the +// returned error. +func (m *DefaultManager) SelectRoutes(ids []route.NetID, appendRoute bool) error { + err := m.selectRoutes(ids, appendRoute) + // Apply regardless of err: selectRoutes already selects the valid part of a + // partial request, and skipping this on error would leave those routes + // selected in the selector but never installed in the routing table. + m.TriggerSelection(m.GetClientRoutes()) + return err +} + +// DeselectRoutes removes the routes with the given network IDs from the +// selection and applies the change. V4/v6 exit-node pairs are expanded +// automatically. A partial failure (e.g. an unknown ID mixed with valid ones) +// still applies the valid IDs to the routing table; the unknown ones are +// reported in the returned error. +func (m *DefaultManager) DeselectRoutes(ids []route.NetID) error { + err := m.deselectRoutes(ids) + // Apply regardless of err: deselectRoutes already deselects the valid part + // of a partial request, and skipping this on error would leave those routes + // installed in the routing table despite being marked deselected. + m.TriggerSelection(m.GetClientRoutes()) + return err +} + +func (m *DefaultManager) deselectRoutes(ids []route.NetID) error { + routesMap := m.GetClientRoutesWithNetID() + routes := route.ExpandV6ExitPairs(slices.Clone(ids), routesMap) + + log.Debugf("deselecting routes with ids: %v", routes) + + if err := m.routeSelector.DeselectRoutes(routes, maps.Keys(routesMap)); err != nil { + return fmt.Errorf("deselect routes: %w", err) + } + + return nil +} + +// SelectAllRoutes selects every available route and applies the selection. +// Exit nodes stay mutually exclusive: at most one remains active. +func (m *DefaultManager) SelectAllRoutes() { + m.selectAllRoutes() + m.TriggerSelection(m.GetClientRoutes()) +} + +func (m *DefaultManager) selectAllRoutes() { + m.routeSelector.SelectAllRoutes() + + // Select-all wipes every explicit selection, so exit nodes fall back to + // management's auto-apply flags — which may mark several at once. + // Reconcile immediately so at most one exit node stays active instead of + // waiting for the next network map to enforce it. + m.mux.Lock() + defer m.mux.Unlock() + m.updateRouteSelectorFromManagement(m.clientRoutes) +} + +// DeselectAllRoutes deselects every route and applies the change. +func (m *DefaultManager) DeselectAllRoutes() { + m.routeSelector.DeselectAllRoutes() + m.TriggerSelection(m.GetClientRoutes()) +} + +func (m *DefaultManager) selectRoutes(ids []route.NetID, appendRoute bool) error { + routesMap := m.GetClientRoutesWithNetID() + routes := route.ExpandV6ExitPairs(slices.Clone(ids), routesMap) + allIDs := maps.Keys(routesMap) + + log.Debugf("selecting routes with ids: %v", routes) + + // A partial failure (e.g. an unknown ID in the request) still selects the + // valid routes, so exclusivity below must run regardless of the error. + var merr *multierror.Error + if err := m.routeSelector.SelectRoutes(routes, appendRoute, allIDs); err != nil { + merr = multierror.Append(merr, fmt.Errorf("select routes: %w", err)) + } + + // Exit nodes are mutually exclusive: if this selection activates an + // exit node, deselect every other available exit node so two can't be + // selected at once. Non-exit route selections are left untouched. + if requestActivatesExitNode(routes, routesMap) { + if others := otherExitNodeIDs(routesMap, routes); len(others) > 0 { + if err := m.routeSelector.DeselectRoutes(others, allIDs); err != nil { + merr = multierror.Append(merr, fmt.Errorf("deselect sibling exit nodes: %w", err)) + } + } + } + + return nberrors.FormatErrorOrNil(merr) +} + +func isExitNodeRoutes(routes []*route.Route) bool { + return len(routes) > 0 && (route.IsV4DefaultRoute(routes[0].Network) || route.IsV6DefaultRoute(routes[0].Network)) +} + +// requestActivatesExitNode reports whether any requested NetID maps to an exit +// node (default route) in the current route table. +func requestActivatesExitNode(requested []route.NetID, routesMap map[route.NetID][]*route.Route) bool { + for _, id := range requested { + if isExitNodeRoutes(routesMap[id]) { + return true + } + } + return false +} + +// otherExitNodeIDs returns every available exit-node NetID that is not in the +// requested set — the siblings to deselect so a single exit node stays active. +func otherExitNodeIDs(routesMap map[route.NetID][]*route.Route, requested []route.NetID) []route.NetID { + keep := make(map[route.NetID]struct{}, len(requested)) + for _, id := range requested { + keep[id] = struct{}{} + } + var others []route.NetID + for id, routes := range routesMap { + if !isExitNodeRoutes(routes) { + continue + } + if _, ok := keep[id]; ok { + continue + } + others = append(others, id) + } + return others +} diff --git a/client/internal/routemanager/selection_test.go b/client/internal/routemanager/selection_test.go new file mode 100644 index 000000000..4ef9ddb88 --- /dev/null +++ b/client/internal/routemanager/selection_test.go @@ -0,0 +1,203 @@ +package routemanager + +import ( + "context" + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/exp/maps" + + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/routemanager/client" + "github.com/netbirdio/netbird/client/internal/routemanager/notifier" + "github.com/netbirdio/netbird/client/internal/routeselector" + "github.com/netbirdio/netbird/route" +) + +func v6ExitRoute(netID, peer string) *route.Route { + return &route.Route{ + NetID: route.NetID(netID), + Network: netip.MustParsePrefix("::/0"), + Peer: peer, + } +} + +func newSelectionTestManager() *DefaultManager { + return &DefaultManager{ + routeSelector: routeselector.NewRouteSelector(), + clientRoutes: route.HAMap{ + "exitA|0.0.0.0/0": {exitRoute("exitA", "p1", true)}, + "exitA-v6|::/0": {v6ExitRoute("exitA-v6", "p1")}, + "exitB|0.0.0.0/0": {exitRoute("exitB", "p2", true)}, + "lan|192.168.1.0/24": {{NetID: "lan", Network: netip.MustParsePrefix("192.168.1.0/24"), Peer: "p3"}}, + }, + } +} + +func TestSelectRoutes_ExitNodeExclusivity(t *testing.T) { + m := newSelectionTestManager() + + // Selecting an exit node selects its v6 pair and deselects the sibling. + require.NoError(t, m.selectRoutes([]route.NetID{"exitA"}, true)) + assert.True(t, m.routeSelector.IsSelected("exitA"), "exitA should be selected") + assert.True(t, m.routeSelector.IsSelected("exitA-v6"), "the v6 pair follows its v4 base") + assert.False(t, m.routeSelector.IsSelected("exitB"), "the sibling exit node must be deselected") + + // Switching to the sibling deselects the previous exit node and its v6 pair. + require.NoError(t, m.selectRoutes([]route.NetID{"exitB"}, true)) + assert.True(t, m.routeSelector.IsSelected("exitB"), "exitB should now be selected") + assert.False(t, m.routeSelector.IsSelected("exitA"), "the previous exit node must be deselected") + assert.False(t, m.routeSelector.IsSelected("exitA-v6"), "the previous exit node's v6 pair must be deselected") + assert.True(t, m.routeSelector.IsSelected("lan"), "non-exit route selection is untouched") + + // Selecting a non-exit route leaves the active exit node alone. + require.NoError(t, m.selectRoutes([]route.NetID{"lan"}, true)) + assert.True(t, m.routeSelector.IsSelected("exitB"), "selecting a non-exit route keeps the exit node") + + // Deselecting the active exit node turns every exit node off. + require.NoError(t, m.deselectRoutes([]route.NetID{"exitB"})) + assert.False(t, m.routeSelector.IsSelected("exitB"), "exitB should be deselected") + assert.False(t, m.routeSelector.IsSelected("exitA"), "exitA stays deselected") + assert.True(t, m.routeSelector.IsSelected("lan"), "non-exit route selection is untouched") +} + +func TestSelectRoutes_PartialErrorStillEnforcesExclusivity(t *testing.T) { + // The unknown ID must be reported, but the valid exit node in the same + // request is still selected — so its sibling must still be deselected. + // Both orderings are covered: processing must continue past the invalid + // ID wherever it sits in the request. + requests := map[string][]route.NetID{ + "invalid id first": {"missing", "exitB"}, + "invalid id last": {"exitB", "missing"}, + } + + for name, ids := range requests { + t.Run(name, func(t *testing.T) { + m := newSelectionTestManager() + + require.NoError(t, m.selectRoutes([]route.NetID{"exitA"}, true)) + + err := m.selectRoutes(ids, true) + assert.Error(t, err, "unknown id must be reported") + assert.True(t, m.routeSelector.IsSelected("exitB"), "valid exit node from the request is selected") + assert.False(t, m.routeSelector.IsSelected("exitA"), "sibling exit node must be deselected despite the error") + assert.False(t, m.routeSelector.IsSelected("exitA-v6"), "sibling's v6 pair must be deselected too") + }) + } +} + +func TestSelectAllRoutes_KeepsSingleExitNode(t *testing.T) { + // Both exit nodes are marked for auto-apply by management + // (SkipAutoApply=false), the state where select-all could turn on two at + // once without the immediate reconciliation. + m := &DefaultManager{ + routeSelector: routeselector.NewRouteSelector(), + clientRoutes: route.HAMap{ + "exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)}, + "exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)}, + "lan|192.168.1.0/24": {{NetID: "lan", Network: netip.MustParsePrefix("192.168.1.0/24"), Peer: "p3"}}, + }, + } + + require.NoError(t, m.selectRoutes([]route.NetID{"exitB"}, true)) + + m.selectAllRoutes() + + assert.True(t, m.routeSelector.IsSelected("lan"), "non-exit routes are all selected") + assert.True(t, m.routeSelector.IsSelected("exitA"), "the deterministic management pick stays active") + assert.False(t, m.routeSelector.IsSelected("exitB"), "select-all must not leave a second exit node active") +} + +func TestSelectRoutes_UnknownRoute(t *testing.T) { + m := newSelectionTestManager() + + assert.Error(t, m.selectRoutes([]route.NetID{"missing"}, true), "selecting an unavailable route must fail") + assert.Error(t, m.deselectRoutes([]route.NetID{"missing"}), "deselecting an unavailable route must fail") +} + +// newPartialFailureTestManager exercises the real install/remove path without +// touching the system: the noop refcounter absorbs the route changes, and every +// route already has a watcher, so none is started. +func newPartialFailureTestManager() *DefaultManager { + ctx := context.Background() + + m := &DefaultManager{ + ctx: ctx, + clientRoutes: route.HAMap{ + "lan|192.168.1.0/24": {{NetID: "lan", Network: netip.MustParsePrefix("192.168.1.0/24"), Peer: "p1"}}, + "other|10.1.2.0/24": {{NetID: "other", Network: netip.MustParsePrefix("10.1.2.0/24"), Peer: "p2"}}, + }, + routeSelector: routeselector.NewRouteSelector(), + notifier: notifier.NewNotifier(), + statusRecorder: peer.NewRecorder("https://mgm"), + activeRoutes: make(map[route.HAUniqueID]client.RouteHandler), + clientNetworks: map[route.HAUniqueID]*client.Watcher{ + "lan|192.168.1.0/24": client.NewWatcher(client.WatcherConfig{Context: ctx}), + "other|10.1.2.0/24": client.NewWatcher(client.WatcherConfig{Context: ctx}), + }, + } + m.setupRefCounters(true) + return m +} + +// Regression for the reported symptom: a partial failure returned before +// TriggerSelection ran, so the valid route was marked selected while never +// reaching the routing table (activeRoutes/ip route). +func TestSelectRoutes_PartialFailureStillInstallsValidRoute(t *testing.T) { + m := newPartialFailureTestManager() + + err := m.SelectRoutes([]route.NetID{"missing", "lan"}, false) + + assert.Error(t, err, "the unknown id must still be reported") + assert.Contains(t, m.activeRoutes, route.HAUniqueID("lan|192.168.1.0/24"), "the valid route must be installed despite the error") + assert.NotContains(t, m.activeRoutes, route.HAUniqueID("other|10.1.2.0/24"), "the deselected route must not be installed") +} + +// Mirror of the case above: a partial failure must remove the valid route from +// the routing table, not just mark it deselected in the selector. +func TestDeselectRoutes_PartialFailureStillRemovesValidRoute(t *testing.T) { + m := newPartialFailureTestManager() + + require.NoError(t, m.SelectRoutes([]route.NetID{"lan", "other"}, false)) + require.Contains(t, m.activeRoutes, route.HAUniqueID("lan|192.168.1.0/24")) + require.Contains(t, m.activeRoutes, route.HAUniqueID("other|10.1.2.0/24")) + + err := m.DeselectRoutes([]route.NetID{"missing", "other"}) + + assert.Error(t, err, "the unknown id must still be reported") + assert.NotContains(t, m.activeRoutes, route.HAUniqueID("other|10.1.2.0/24"), "the deselected route must be removed") + assert.Contains(t, m.activeRoutes, route.HAUniqueID("lan|192.168.1.0/24"), "the untouched route stays installed") +} + +// The selection now runs on every request, including one where no ID is known +// and the selector stays untouched. Nothing may be torn down or reinstalled on +// that path. +func TestSelectRoutes_TotalFailureLeavesInstalledRoutesAlone(t *testing.T) { + m := newPartialFailureTestManager() + + require.NoError(t, m.SelectRoutes([]route.NetID{"lan", "other"}, false)) + installed := maps.Keys(m.activeRoutes) + + err := m.SelectRoutes([]route.NetID{"missing"}, false) + + assert.Error(t, err, "the unknown id must still be reported") + assert.ElementsMatch(t, installed, maps.Keys(m.activeRoutes), "a fully invalid request must not disturb the routing table") +} + +func TestExitNodeSelectionHelpers(t *testing.T) { + routesMap := map[route.NetID][]*route.Route{ + "exitA": {{Network: netip.MustParsePrefix("0.0.0.0/0")}}, + "exitB": {{Network: netip.MustParsePrefix("::/0")}}, + "lan": {{Network: netip.MustParsePrefix("192.168.0.0/16")}}, + } + + assert.True(t, requestActivatesExitNode([]route.NetID{"exitA"}, routesMap), "v4 default route is an exit node") + assert.True(t, requestActivatesExitNode([]route.NetID{"exitB"}, routesMap), "v6 default route is an exit node") + assert.False(t, requestActivatesExitNode([]route.NetID{"lan"}, routesMap), "lan route is not an exit node") + assert.False(t, requestActivatesExitNode([]route.NetID{"missing"}, routesMap), "unknown id is not an exit node") + + others := otherExitNodeIDs(routesMap, []route.NetID{"exitB"}) + assert.ElementsMatch(t, []route.NetID{"exitA"}, others, "only the other exit node is a sibling; the lan route is ignored") +} diff --git a/client/internal/routemanager/server/server.go b/client/internal/routemanager/server/server.go index f569c0cac..38d7f0db4 100644 --- a/client/internal/routemanager/server/server.go +++ b/client/internal/routemanager/server/server.go @@ -135,6 +135,14 @@ func (r *Router) CleanUp() { } } + // Give back the routing reference taken in UpdateRoutes, after the routes + // are gone as above. Without this the sysctls enabling it changed (IPv6 + // forwarding and the accept_ra values that keep RA handling alive next to + // it) stay applied once the client stops. + if err := r.firewall.DisableRouting(); err != nil { + log.Errorf("Failed to disable routing: %v", err) + } + r.statusRecorder.CleanLocalPeerStateRoutes() } diff --git a/client/internal/routemanager/server/server_test.go b/client/internal/routemanager/server/server_test.go new file mode 100644 index 000000000..1b42115e2 --- /dev/null +++ b/client/internal/routemanager/server/server_test.go @@ -0,0 +1,66 @@ +package server + +import ( + "context" + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + firewall "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/route" +) + +// routingFirewall records the routing lifecycle calls the router makes. The +// embedded interface covers the methods this test never reaches. +type routingFirewall struct { + firewall.Manager + + removed []firewall.RouterPair + enabled int + disabled int +} + +func (f *routingFirewall) RemoveNatRule(pair firewall.RouterPair) error { + f.removed = append(f.removed, pair) + return nil +} + +func (f *routingFirewall) EnableRouting() error { + f.enabled++ + return nil +} + +func (f *routingFirewall) DisableRouting() error { + f.disabled++ + return nil +} + +// TestRouterCleanUpReleasesRouting covers the shutdown path: the router holds a +// routing reference for as long as it serves routes, and CleanUp has to give it +// back. Without that the sysctls the reference enabled (IPv6 forwarding and the +// accept_ra values that keep RA handling working alongside it) stay applied +// after the client stops, leaving the host configured as a router. +func TestRouterCleanUpReleasesRouting(t *testing.T) { + fw := &routingFirewall{} + r := &Router{ + ctx: context.Background(), + firewall: fw, + statusRecorder: peer.NewRecorder("https://mgm"), + routes: map[route.ID]*route.Route{ + "route-1": { + ID: "route-1", + Network: netip.MustParsePrefix("192.168.55.0/24"), + NetworkType: route.IPv4Network, + Masquerade: true, + }, + }, + } + + r.CleanUp() + + require.Len(t, fw.removed, 1, "the route's NAT rule must be removed") + assert.Equal(t, 1, fw.disabled, "CleanUp must release the routing reference") +} diff --git a/client/internal/routemanager/static/route.go b/client/internal/routemanager/static/route.go index d480fdf00..8ba03d090 100644 --- a/client/internal/routemanager/static/route.go +++ b/client/internal/routemanager/static/route.go @@ -15,6 +15,11 @@ type Route struct { route *route.Route routeRefCounter *refcounter.RouteRefCounter allowedIPsRefcounter *refcounter.AllowedIPsRefCounter + // currentPeerKey is the routing peer this watcher currently has the prefix installed on + // (the HA winner elected by the watcher). It can differ from route.Peer and change on + // failover, so it is recorded on AddAllowedIPs and used on RemoveAllowedIPs to decrement + // the exact peer that was incremented. + currentPeerKey string } func NewRoute(params common.HandlerParams) *Route { @@ -52,12 +57,15 @@ func (r *Route) AddAllowedIPs(peerKey string) error { ref.Out, ) } + r.currentPeerKey = peerKey return nil } func (r *Route) RemoveAllowedIPs() error { - if _, err := r.allowedIPsRefcounter.Decrement(r.route.Network); err != nil { - return err + var err error + if _, decErr := r.allowedIPsRefcounter.Decrement(r.route.Network, r.currentPeerKey); decErr != nil { + err = fmt.Errorf("remove allowed IP %s: %w", r.route.Network, decErr) } - return nil + r.currentPeerKey = "" + return err } diff --git a/client/internal/routemanager/sysctl/sysctl_linux.go b/client/internal/routemanager/sysctl/sysctl_linux.go index f96a57f37..bb131c691 100644 --- a/client/internal/routemanager/sysctl/sysctl_linux.go +++ b/client/internal/routemanager/sysctl/sysctl_linux.go @@ -20,6 +20,8 @@ const ( rpFilterPath = "net.ipv4.conf.all.rp_filter" rpFilterInterfacePath = "net.ipv4.conf.%s.rp_filter" srcValidMarkPath = "net.ipv4.conf.all.src_valid_mark" + percentEscape = "%25" + dotEscape = "%2E" ) type iface interface { @@ -56,7 +58,7 @@ func Setup(wgIface iface) (map[string]int, error) { continue } - i := fmt.Sprintf(rpFilterInterfacePath, intf.Name) + i := fmt.Sprintf(rpFilterInterfacePath, EscapeInterfaceName(intf.Name)) oldVal, err := Set(i, 2, true) if err != nil { result = multierror.Append(result, err) @@ -68,9 +70,20 @@ func Setup(wgIface iface) (map[string]int, error) { return keys, nberrors.FormatErrorOrNil(result) } +// EscapeInterfaceName escapes '%' and '.' in an interface name (e.g. VLANs +// like eth0.100) so the name survives the dot-to-slash conversion in Set. +func EscapeInterfaceName(name string) string { + safe := strings.ReplaceAll(name, "%", percentEscape) + return strings.ReplaceAll(safe, ".", dotEscape) +} + // Set sets a sysctl configuration, if onlyIfOne is true it will only set the new value if it's set to 1 func Set(key string, desiredValue int, onlyIfOne bool) (int, error) { - path := fmt.Sprintf("/proc/sys/%s", strings.ReplaceAll(key, ".", "/")) + path := strings.ReplaceAll(key, ".", "/") + // Unescape interface dots and percent signs + path = strings.ReplaceAll(path, dotEscape, ".") + path = strings.ReplaceAll(path, percentEscape, "%") + path = fmt.Sprintf("/proc/sys/%s", path) currentValue, err := os.ReadFile(path) if err != nil { return -1, fmt.Errorf("read sysctl %s: %w", key, err) diff --git a/client/internal/routemanager/systemops/routeselection_windows_test.go b/client/internal/routemanager/systemops/routeselection_windows_test.go new file mode 100644 index 000000000..108338dd9 --- /dev/null +++ b/client/internal/routemanager/systemops/routeselection_windows_test.go @@ -0,0 +1,82 @@ +//go:build windows + +package systemops + +import ( + "math" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSortRouteCandidates(t *testing.T) { + tests := []struct { + name string + candidates []candidateRoute + wantOrder []uint32 + }{ + { + name: "longest prefix wins over metrics", + candidates: []candidateRoute{ + {interfaceIndex: 1, prefixLength: 0, routeMetric: 0, interfaceMetric: 5}, + {interfaceIndex: 2, prefixLength: 24, routeMetric: 100, interfaceMetric: 50}, + }, + wantOrder: []uint32{2, 1}, + }, + { + // Windows ranks equal-length prefixes by route metric + interface metric, + // so a higher route metric on a low metric interface can still win. + name: "combined metric beats route metric alone", + candidates: []candidateRoute{ + {interfaceIndex: 8, prefixLength: 0, routeMetric: 0, interfaceMetric: 100}, + {interfaceIndex: 5, prefixLength: 0, routeMetric: 10, interfaceMetric: 5}, + }, + wantOrder: []uint32{5, 8}, + }, + { + name: "lower combined metric wins", + candidates: []candidateRoute{ + {interfaceIndex: 5, prefixLength: 0, routeMetric: 300, interfaceMetric: 5}, + {interfaceIndex: 8, prefixLength: 0, routeMetric: 0, interfaceMetric: 100}, + }, + wantOrder: []uint32{8, 5}, + }, + { + name: "equal combined metric falls back to route metric", + candidates: []candidateRoute{ + {interfaceIndex: 1, prefixLength: 0, routeMetric: 20, interfaceMetric: 10}, + {interfaceIndex: 2, prefixLength: 0, routeMetric: 5, interfaceMetric: 25}, + }, + wantOrder: []uint32{2, 1}, + }, + { + // The metrics are uint32 on the Windows side, so the sum must not wrap. + name: "combined metric beyond the uint32 range", + candidates: []candidateRoute{ + {interfaceIndex: 1, prefixLength: 0, routeMetric: math.MaxUint32, interfaceMetric: 5}, + {interfaceIndex: 2, prefixLength: 0, routeMetric: math.MaxUint32 - 10, interfaceMetric: 5}, + }, + wantOrder: []uint32{2, 1}, + }, + { + name: "unknown interface metric ranks on route metric only", + candidates: []candidateRoute{ + {interfaceIndex: 1, prefixLength: 0, routeMetric: 30, interfaceMetric: -1}, + {interfaceIndex: 2, prefixLength: 0, routeMetric: 5, interfaceMetric: 10}, + }, + wantOrder: []uint32{2, 1}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sortRouteCandidates(tt.candidates) + + got := make([]uint32, 0, len(tt.candidates)) + for _, c := range tt.candidates { + got = append(got, c.interfaceIndex) + } + assert.Equal(t, tt.wantOrder, got) + }) + } +} diff --git a/client/internal/routemanager/systemops/systemops_android.go b/client/internal/routemanager/systemops/systemops_android.go index 7cb8dae93..97b4ed8ec 100644 --- a/client/internal/routemanager/systemops/systemops_android.go +++ b/client/internal/routemanager/systemops/systemops_android.go @@ -32,8 +32,17 @@ func (r *SysOps) removeFromRouteTable(netip.Prefix, Nexthop) error { return nil } -func EnableIPForwarding() error { - log.Infof("Enable IP forwarding is not implemented on %s", runtime.GOOS) +func EnableV4IPForwarding() error { + log.Infof("Enable IPv4 forwarding is not implemented on %s", runtime.GOOS) + return nil +} + +func EnableV6IPForwarding(string) (map[string]int, error) { + log.Infof("Enable IPv6 forwarding is not implemented on %s", runtime.GOOS) + return map[string]int{}, nil +} + +func DisableV6IPForwarding(map[string]int) error { return nil } diff --git a/client/internal/routemanager/systemops/systemops_ios.go b/client/internal/routemanager/systemops/systemops_ios.go index 99a363371..0cccd4962 100644 --- a/client/internal/routemanager/systemops/systemops_ios.go +++ b/client/internal/routemanager/systemops/systemops_ios.go @@ -58,8 +58,17 @@ func (r *SysOps) removeFromRouteTable(netip.Prefix, Nexthop) error { return nil } -func EnableIPForwarding() error { - log.Infof("Enable IP forwarding is not implemented on %s", runtime.GOOS) +func EnableV4IPForwarding() error { + log.Infof("Enable IPv4 forwarding is not implemented on %s", runtime.GOOS) + return nil +} + +func EnableV6IPForwarding(string) (map[string]int, error) { + log.Infof("Enable IPv6 forwarding is not implemented on %s", runtime.GOOS) + return map[string]int{}, nil +} + +func DisableV6IPForwarding(map[string]int) error { return nil } diff --git a/client/internal/routemanager/systemops/systemops_linux.go b/client/internal/routemanager/systemops/systemops_linux.go index 8c6b7d9a9..7d608d886 100644 --- a/client/internal/routemanager/systemops/systemops_linux.go +++ b/client/internal/routemanager/systemops/systemops_linux.go @@ -763,13 +763,10 @@ func flushRoutes(tableID, family int) error { return nberrors.FormatErrorOrNil(result) } -func EnableIPForwarding() error { +func EnableV4IPForwarding() error { if _, err := sysctl.Set(ipv4ForwardingPath, 1, false); err != nil { return err } - if _, err := sysctl.Set(ipv6ForwardingPath, 1, false); err != nil { - log.Warnf("failed to enable IPv6 forwarding: %v", err) - } return nil } diff --git a/client/internal/routemanager/systemops/systemops_nonlinux.go b/client/internal/routemanager/systemops/systemops_nonlinux.go index 016a62ebd..837ac0cd2 100644 --- a/client/internal/routemanager/systemops/systemops_nonlinux.go +++ b/client/internal/routemanager/systemops/systemops_nonlinux.go @@ -43,8 +43,17 @@ func (r *SysOps) RemoveVPNRoute(prefix netip.Prefix, intf *net.Interface) error return r.genericRemoveVPNRoute(prefix, intf) } -func EnableIPForwarding() error { - log.Infof("Enable IP forwarding is not implemented on %s", runtime.GOOS) +func EnableV4IPForwarding() error { + log.Infof("Enable IPv4 forwarding is not implemented on %s", runtime.GOOS) + return nil +} + +func EnableV6IPForwarding(string) (map[string]int, error) { + log.Infof("Enable IPv6 forwarding is not implemented on %s", runtime.GOOS) + return map[string]int{}, nil +} + +func DisableV6IPForwarding(map[string]int) error { return nil } diff --git a/client/internal/routemanager/systemops/systemops_windows.go b/client/internal/routemanager/systemops/systemops_windows.go index 7bce6af80..47d556cf6 100644 --- a/client/internal/routemanager/systemops/systemops_windows.go +++ b/client/internal/routemanager/systemops/systemops_windows.go @@ -882,26 +882,40 @@ func getInterfaceMetric(interfaceIndex uint32, family int16) int { return int(ipInterfaceRow.Metric) } -// sortRouteCandidates sorts route candidates by priority: prefix length -> route metric -> interface metric +// sortRouteCandidates sorts route candidates by priority: prefix length -> combined metric -> route metric. +// Windows prefers the longest matching prefix and, among prefixes of the same length, the lowest metric, see +// https://learn.microsoft.com/en-us/windows-hardware/customize/desktop/unattend/microsoft-windows-tcpip-interfaces-interface-routes-route-metric func sortRouteCandidates(candidates []candidateRoute) { sort.Slice(candidates, func(i, j int) bool { if candidates[i].prefixLength != candidates[j].prefixLength { return candidates[i].prefixLength > candidates[j].prefixLength } - if candidates[i].routeMetric != candidates[j].routeMetric { - return candidates[i].routeMetric < candidates[j].routeMetric + mi, mj := combinedMetric(candidates[i]), combinedMetric(candidates[j]) + if mi != mj { + return mi < mj } - return candidates[i].interfaceMetric < candidates[j].interfaceMetric + return candidates[i].routeMetric < candidates[j].routeMetric }) } +// combinedMetric returns the effective metric Windows uses to rank routes with an equal prefix length: +// the sum of the route metric and the metric of the interface the route is on, see +// https://learn.microsoft.com/en-us/windows-server/networking/technologies/network-subsystem/net-sub-interface-metric +// An unknown interface metric contributes nothing. +func combinedMetric(candidate candidateRoute) uint64 { + if candidate.interfaceMetric < 0 { + return uint64(candidate.routeMetric) + } + return uint64(candidate.routeMetric) + uint64(candidate.interfaceMetric) +} + // GetBestInterface finds the best interface for reaching a destination, // excluding the VPN interface to avoid routing loops. // // Route selection priority: // 1. Longest prefix match (most specific route) -// 2. Lowest route metric -// 3. Lowest interface metric +// 2. Lowest combined metric (route metric + interface metric) +// 3. Lowest route metric. func GetBestInterface(dest netip.Addr, vpnIntf string) (*net.Interface, error) { var skipInterfaceIndex int if vpnIntf != "" { @@ -925,7 +939,6 @@ func GetBestInterface(dest netip.Addr, vpnIntf string) (*net.Interface, error) { return nil, fmt.Errorf("no route to %s", dest) } - // Sort routes: prefix length -> route metric -> interface metric sortRouteCandidates(candidates) for _, candidate := range candidates { diff --git a/client/internal/routemanager/systemops/v6forwarding_linux.go b/client/internal/routemanager/systemops/v6forwarding_linux.go new file mode 100644 index 000000000..c1e0d4588 --- /dev/null +++ b/client/internal/routemanager/systemops/v6forwarding_linux.go @@ -0,0 +1,92 @@ +//go:build !android + +package systemops + +import ( + "fmt" + "net" + "os" + + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + + nberrors "github.com/netbirdio/netbird/client/errors" + "github.com/netbirdio/netbird/client/internal/routemanager/sysctl" +) + +const ( + // 1 (default) accepts RAs only while forwarding is off; 2 keeps RA + // acceptance on regardless, so RA-installed host defaults survive our + // v6 forwarding flip. + acceptRAInterfacePath = "net.ipv6.conf.%s.accept_ra" + acceptRADefaultPath = "net.ipv6.conf.default.accept_ra" + acceptRAProcPathFormat = "/proc/sys/net/ipv6/conf/%s/accept_ra" +) + +// EnableV6IPForwarding bumps accept_ra=2 on host v6 interfaces before flipping +// forwarding=1, so RA-installed host defaults survive. Returns the prior values +// of sysctls we actually changed; entries already at the target are omitted. +func EnableV6IPForwarding(wgIfaceName string) (map[string]int, error) { + saved := map[string]int{} + bumpAcceptRA(saved, wgIfaceName) + + oldVal, err := sysctl.Set(ipv6ForwardingPath, 1, false) + if err != nil { + return saved, err + } + if oldVal != 1 { + saved[ipv6ForwardingPath] = oldVal + } + return saved, nil +} + +// DisableV6IPForwarding restores what EnableV6IPForwarding captured. +func DisableV6IPForwarding(saved map[string]int) error { + var result *multierror.Error + for key, value := range saved { + if _, err := sysctl.Set(key, value, false); err != nil { + result = multierror.Append(result, fmt.Errorf("restore %s: %w", key, err)) + } + } + return nberrors.FormatErrorOrNil(result) +} + +func bumpAcceptRA(saved map[string]int, wgIfaceName string) { + // Also bump conf.default so interfaces created while forwarding is on + // (hotplug, new Wi-Fi/dock) inherit accept_ra=2 and keep accepting RAs. + bumpAcceptRAKey(saved, acceptRADefaultPath) + + interfaces, err := net.Interfaces() + if err != nil { + log.Warnf("list interfaces for accept_ra: %v", err) + return + } + for _, intf := range interfaces { + if intf.Name == "lo" || intf.Name == wgIfaceName { + continue + } + bumpAcceptRAForInterface(saved, intf.Name) + } +} + +func bumpAcceptRAForInterface(saved map[string]int, name string) { + // Build procfs path from name, not the dotted key: VLAN names like eth0.100. + if _, err := os.Stat(fmt.Sprintf(acceptRAProcPathFormat, name)); err != nil { + return + } + bumpAcceptRAKey(saved, fmt.Sprintf(acceptRAInterfacePath, sysctl.EscapeInterfaceName(name))) +} + +func bumpAcceptRAKey(saved map[string]int, key string) { + // onlyIfOne=true: leave admin overrides (0, 2) alone. + oldVal, err := sysctl.Set(key, 2, true) + if err != nil { + log.Warnf("bump %s: %v", key, err) + return + } + // With onlyIfOne, a write only happened when the old value was 1; values + // left untouched (0, 2) must not be recorded for restore. + if oldVal == 1 { + saved[key] = oldVal + } +} diff --git a/client/internal/routemanager/systemops/v6route_linux_test.go b/client/internal/routemanager/systemops/v6route_linux_test.go index 449d4cbd2..d8c0012d1 100644 --- a/client/internal/routemanager/systemops/v6route_linux_test.go +++ b/client/internal/routemanager/systemops/v6route_linux_test.go @@ -5,6 +5,7 @@ package systemops import ( "errors" "net" + "net/netip" "syscall" "testing" @@ -29,6 +30,7 @@ func ensureIPv6DefaultRoute(t *testing.T) { } if err := netlink.RouteAdd(route); err != nil { if errors.Is(err, syscall.EEXIST) { + requireUsableIPv6Nexthop(t) return } t.Skipf("install IPv6 fallback default route: %v", err) @@ -38,4 +40,36 @@ func ensureIPv6DefaultRoute(t *testing.T) { t.Logf("delete IPv6 fallback default route: %v", err) } }) + + requireUsableIPv6Nexthop(t) +} + +// requireUsableIPv6Nexthop skips the test unless the resolved IPv6 default +// nexthop can actually carry a route. Installing the default route succeeding +// does not imply the kernel accepts it as a nexthop for a concrete prefix. +func requireUsableIPv6Nexthop(t *testing.T) { + t.Helper() + + nexthop, err := GetNextHop(netip.IPv6Unspecified()) + if err != nil { + t.Skipf("resolve IPv6 default nexthop: %v", err) + } + + probe := &netlink.Route{ + Scope: netlink.SCOPE_UNIVERSE, + Table: syscall.RT_TABLE_MAIN, + Family: netlink.FAMILY_V6, + Dst: &net.IPNet{IP: net.ParseIP("100::64"), Mask: net.CIDRMask(128, 128)}, + } + require.NoError(t, addNextHop(nexthop, probe), "build IPv6 probe route") + + switch err := netlink.RouteAdd(probe); { + case err == nil: + if err := netlink.RouteDel(probe); err != nil && !errors.Is(err, syscall.ESRCH) { + t.Logf("delete IPv6 probe route: %v", err) + } + case errors.Is(err, syscall.EEXIST): + default: + t.Skipf("IPv6 nexthop %s unusable for route installation: %v", nexthop, err) + } } diff --git a/client/internal/routeselector/routeselector.go b/client/internal/routeselector/routeselector.go index 1254b384d..8a64ad316 100644 --- a/client/internal/routeselector/routeselector.go +++ b/client/internal/routeselector/routeselector.go @@ -32,6 +32,22 @@ func (rs *RouteSelector) SelectRoutes(routes []route.NetID, appendRoute bool, al rs.mu.Lock() defer rs.mu.Unlock() + // Validate before mutating: a non-append selection wipes the current selection + // first, so a request of only unavailable routes would deselect everything and + // put nothing back. An empty request means deselect all, so it still goes through. + var err *multierror.Error + available := make([]route.NetID, 0, len(routes)) + for _, r := range routes { + if !slices.Contains(allRoutes, r) { + err = multierror.Append(err, fmt.Errorf("route '%s' is not available", r)) + continue + } + available = append(available, r) + } + if len(available) == 0 && err != nil { + return errors.FormatErrorOrNil(err) + } + if !appendRoute || rs.deselectAll { if rs.deselectedRoutes == nil { rs.deselectedRoutes = map[route.NetID]struct{}{} @@ -46,14 +62,9 @@ func (rs *RouteSelector) SelectRoutes(routes []route.NetID, appendRoute bool, al } } - var err *multierror.Error - for _, route := range routes { - if !slices.Contains(allRoutes, route) { - err = multierror.Append(err, fmt.Errorf("route '%s' is not available", route)) - continue - } - delete(rs.deselectedRoutes, route) - rs.selectedRoutes[route] = struct{}{} + for _, r := range available { + delete(rs.deselectedRoutes, r) + rs.selectedRoutes[r] = struct{}{} } rs.deselectAll = false diff --git a/client/internal/routeselector/routeselector_test.go b/client/internal/routeselector/routeselector_test.go index 2b1ba3fb9..f26d022e9 100644 --- a/client/internal/routeselector/routeselector_test.go +++ b/client/internal/routeselector/routeselector_test.go @@ -887,3 +887,70 @@ func TestRouteSelector_EnableExitNodeKeepsOtherRoutes(t *testing.T) { assert.True(t, rs.IsSelected("lan1"), "non-exit route must stay selected") assert.True(t, rs.IsSelected("lan2"), "non-exit route must stay selected") } + +// A non-append selection clears the current selection before applying the requested +// one, so an all-unavailable request used to leave nothing selected while returning +// an error. Requests with at least one available route are unaffected. +func TestRouteSelector_SelectRoutes_AllUnavailableKeepsSelection(t *testing.T) { + allRoutes := []route.NetID{"route1", "route2", "route3"} + + rs := routeselector.NewRouteSelector() + require.NoError(t, rs.SelectRoutes([]route.NetID{"route1"}, false, allRoutes)) + + err := rs.SelectRoutes([]route.NetID{"Route1", "route4"}, false, allRoutes) + + assert.Error(t, err, "an unavailable route ID must still be reported") + assert.True(t, rs.IsSelected("route1"), "the previous selection must survive a fully invalid request") + for _, id := range []route.NetID{"route2", "route3"} { + assert.False(t, rs.IsSelected(id), "no other route may become selected") + } +} + +// Boundary of the check above: an empty request is the caller deselecting everything, +// not a failed lookup, so it must keep working. +func TestRouteSelector_SelectRoutes_EmptyRequestStillDeselectsAll(t *testing.T) { + allRoutes := []route.NetID{"route1", "route2", "route3"} + + rs := routeselector.NewRouteSelector() + require.NoError(t, rs.SelectRoutes([]route.NetID{"route1"}, false, allRoutes)) + + require.NoError(t, rs.SelectRoutes(nil, false, allRoutes)) + + for _, id := range allRoutes { + assert.False(t, rs.IsSelected(id), "an empty selection request must deselect everything") + } +} + +// Mobile clients always call SelectRoutes with append=true. On that path an +// all-unavailable request was never destructive to begin with (append skips the +// wipe regardless of the guard above), but the behavior has no coverage yet. +func TestRouteSelector_SelectRoutes_AppendAllUnavailableKeepsSelection(t *testing.T) { + allRoutes := []route.NetID{"route1", "route2", "route3"} + + rs := routeselector.NewRouteSelector() + require.NoError(t, rs.SelectRoutes([]route.NetID{"route1"}, false, allRoutes)) + + err := rs.SelectRoutes([]route.NetID{"missing"}, true, allRoutes) + + assert.Error(t, err, "an unavailable route ID must still be reported") + assert.True(t, rs.IsSelected("route1"), "the previous selection must survive a fully invalid request") + for _, id := range []route.NetID{"route2", "route3"} { + assert.False(t, rs.IsSelected(id), "no other route may become selected") + } +} + +// The early return for an all-unavailable request must not clear deselectAll, +// or a typo'd network ID would silently drop the "nothing selected, including +// future networks" policy. +func TestRouteSelector_SelectRoutes_AllUnavailableAfterDeselectAllKeepsPolicy(t *testing.T) { + allRoutes := []route.NetID{"route1", "route2"} + + rs := routeselector.NewRouteSelector() + rs.DeselectAllRoutes() + + err := rs.SelectRoutes([]route.NetID{"missing"}, false, allRoutes) + + assert.Error(t, err, "an unavailable route ID must still be reported") + assert.True(t, rs.IsDeselectAll(), "deselect-all policy must survive a fully invalid request") + assert.False(t, rs.IsSelected("route3"), "deselect-all must still cover networks not present in allRoutes yet") +} diff --git a/client/internal/sleep/service.go b/client/internal/sleep/service.go index 196a33f52..93691c4c7 100644 --- a/client/internal/sleep/service.go +++ b/client/internal/sleep/service.go @@ -18,8 +18,8 @@ type Service struct { } func New() (*Service, error) { - d, err := NewDetector() - if err != nil { + d, err := NewDetector() //nolint:staticcheck + if err != nil { //nolint:staticcheck // always errors on platforms without a sleep detector return nil, err } diff --git a/client/internal/statemanager/manager.go b/client/internal/statemanager/manager.go index 566905985..ca4194690 100644 --- a/client/internal/statemanager/manager.go +++ b/client/internal/statemanager/manager.go @@ -1,6 +1,7 @@ package statemanager import ( + "bytes" "context" "encoding/json" "errors" @@ -305,6 +306,11 @@ func (m *Manager) loadStateFile(deleteCorrupt bool) (map[string]json.RawMessage, var rawStates map[string]json.RawMessage if err := json.Unmarshal(data, &rawStates); err != nil { + if len(bytes.TrimSpace(data)) == 0 { + log.Warnf("state file %s is empty (%d bytes)", m.filePath, len(data)) + } else { + log.Warnf("state file %s has malformed content (%d bytes)", m.filePath, len(data)) + } m.handleCorruptedState(deleteCorrupt) return nil, fmt.Errorf("unmarshal states: %w", err) } diff --git a/client/internal/tunnelnotifier/notifier.go b/client/internal/tunnelnotifier/notifier.go new file mode 100644 index 000000000..b62923a6e --- /dev/null +++ b/client/internal/tunnelnotifier/notifier.go @@ -0,0 +1,124 @@ +package tunnelnotifier + +import ( + "container/list" + "sync" + + "github.com/netbirdio/netbird/client/internal/dns" + "github.com/netbirdio/netbird/client/internal/listener" +) + +type eventKind int + +const ( + eventRoutes eventKind = iota + eventIfaceIP + eventIfaceIPv6 + eventDNS +) + +var ( + _ listener.NetworkChangeListener = (*Notifier)(nil) + _ dns.IosDnsManager = (*Notifier)(nil) +) + +type event struct { + kind eventKind + payload string +} + +type Notifier struct { + mu sync.Mutex + cond *sync.Cond + queue *list.List + closed bool + done chan struct{} + + listener listener.NetworkChangeListener + dnsManager dns.IosDnsManager +} + +func New(l listener.NetworkChangeListener, dm dns.IosDnsManager) *Notifier { + n := &Notifier{ + queue: list.New(), + done: make(chan struct{}), + listener: l, + dnsManager: dm, + } + n.cond = sync.NewCond(&n.mu) + go n.deliverLoop() + return n +} + +func (n *Notifier) OnNetworkChanged(routes string) { + n.enqueue(event{kind: eventRoutes, payload: routes}) +} + +func (n *Notifier) SetInterfaceIP(ip string) { + n.enqueue(event{kind: eventIfaceIP, payload: ip}) +} + +func (n *Notifier) SetInterfaceIPv6(ip string) { + n.enqueue(event{kind: eventIfaceIPv6, payload: ip}) +} + +func (n *Notifier) ApplyDns(config string) { + n.enqueue(event{kind: eventDNS, payload: config}) +} + +// Close stops accepting new events and blocks until the delivery loop has +// drained all queued events and exited. +func (n *Notifier) Close() { + n.mu.Lock() + n.closed = true + n.cond.Signal() + n.mu.Unlock() + <-n.done +} + +func (n *Notifier) enqueue(ev event) { + n.mu.Lock() + defer n.mu.Unlock() + if n.closed { + return + } + n.queue.PushBack(ev) + n.cond.Signal() +} + +func (n *Notifier) deliverLoop() { + defer close(n.done) + for { + n.mu.Lock() + for n.queue.Len() == 0 && !n.closed { + n.cond.Wait() + } + if n.closed && n.queue.Len() == 0 { + n.mu.Unlock() + return + } + ev := n.queue.Remove(n.queue.Front()).(event) + l := n.listener + dm := n.dnsManager + n.mu.Unlock() + + switch ev.kind { + case eventRoutes: + if l != nil { + l.OnNetworkChanged(ev.payload) + } + case eventIfaceIP: + if l != nil { + l.SetInterfaceIP(ev.payload) + } + case eventIfaceIPv6: + if l != nil { + l.SetInterfaceIPv6(ev.payload) + } + case eventDNS: + if dm != nil { + dm.ApplyDns(ev.payload) + } + } + } +} diff --git a/client/internal/tunnelnotifier/notifier_test.go b/client/internal/tunnelnotifier/notifier_test.go new file mode 100644 index 000000000..ffbcdc15c --- /dev/null +++ b/client/internal/tunnelnotifier/notifier_test.go @@ -0,0 +1,192 @@ +package tunnelnotifier + +import ( + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type call struct { + kind string + payload string +} + +type recorder struct { + mu sync.Mutex + calls []call + inFlight atomic.Int32 + overlap atomic.Bool + delay time.Duration +} + +func (r *recorder) record(kind, payload string) { + if r.inFlight.Add(1) != 1 { + r.overlap.Store(true) + } + if r.delay > 0 { + time.Sleep(r.delay) + } + r.mu.Lock() + r.calls = append(r.calls, call{kind: kind, payload: payload}) + r.mu.Unlock() + r.inFlight.Add(-1) +} + +func (r *recorder) count() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.calls) +} + +func (r *recorder) snapshot() []call { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]call, len(r.calls)) + copy(out, r.calls) + return out +} + +type fakeListener struct { + rec *recorder +} + +func (f *fakeListener) OnNetworkChanged(routes string) { + f.rec.record("routes", routes) +} + +func (f *fakeListener) SetInterfaceIP(ip string) { + f.rec.record("ip", ip) +} + +func (f *fakeListener) SetInterfaceIPv6(ip string) { + f.rec.record("ipv6", ip) +} + +type fakeDNSManager struct { + rec *recorder +} + +func (f *fakeDNSManager) ApplyDns(config string) { + f.rec.record("dns", config) +} + +func TestFIFOOrder(t *testing.T) { + rec := &recorder{} + n := New(&fakeListener{rec: rec}, &fakeDNSManager{rec: rec}) + defer n.Close() + + n.SetInterfaceIP("10.0.0.1") + n.SetInterfaceIPv6("fd00::1") + n.ApplyDns(`{"domains":[]}`) + n.OnNetworkChanged("10.0.0.0/8,192.168.0.0/16") + n.ApplyDns(`{"domains":["example.com"]}`) + + require.Eventually(t, func() bool { return rec.count() == 5 }, time.Second, time.Millisecond) + + expected := []call{ + {kind: "ip", payload: "10.0.0.1"}, + {kind: "ipv6", payload: "fd00::1"}, + {kind: "dns", payload: `{"domains":[]}`}, + {kind: "routes", payload: "10.0.0.0/8,192.168.0.0/16"}, + {kind: "dns", payload: `{"domains":["example.com"]}`}, + } + assert.Equal(t, expected, rec.snapshot()) +} + +func TestNoOverlappingCalls(t *testing.T) { + rec := &recorder{delay: 100 * time.Microsecond} + n := New(&fakeListener{rec: rec}, &fakeDNSManager{rec: rec}) + defer n.Close() + + const producers = 8 + const perProducer = 25 + + var wg sync.WaitGroup + for i := 0; i < producers; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := 0; j < perProducer; j++ { + payload := fmt.Sprintf("%d-%d", id, j) + switch j % 4 { + case 0: + n.OnNetworkChanged(payload) + case 1: + n.SetInterfaceIP(payload) + case 2: + n.SetInterfaceIPv6(payload) + case 3: + n.ApplyDns(payload) + } + } + }(i) + } + wg.Wait() + + require.Eventually(t, func() bool { return rec.count() == producers*perProducer }, 5*time.Second, time.Millisecond) + assert.False(t, rec.overlap.Load()) +} + +func TestDNSAndRoutesInterleaved(t *testing.T) { + rec := &recorder{delay: 100 * time.Microsecond} + n := New(&fakeListener{rec: rec}, &fakeDNSManager{rec: rec}) + defer n.Close() + + const events = 50 + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + for i := 0; i < events; i++ { + n.ApplyDns(fmt.Sprintf("dns-%d", i)) + } + }() + go func() { + defer wg.Done() + for i := 0; i < events; i++ { + n.OnNetworkChanged(fmt.Sprintf("routes-%d", i)) + } + }() + wg.Wait() + + require.Eventually(t, func() bool { return rec.count() == 2*events }, 5*time.Second, time.Millisecond) + assert.False(t, rec.overlap.Load()) + + var dnsSeen, routesSeen int + for _, c := range rec.snapshot() { + switch c.kind { + case "dns": + assert.Equal(t, fmt.Sprintf("dns-%d", dnsSeen), c.payload) + dnsSeen++ + case "routes": + assert.Equal(t, fmt.Sprintf("routes-%d", routesSeen), c.payload) + routesSeen++ + } + } + assert.Equal(t, events, dnsSeen) + assert.Equal(t, events, routesSeen) +} + +func TestCloseDrainsQueue(t *testing.T) { + rec := &recorder{delay: time.Millisecond} + n := New(&fakeListener{rec: rec}, &fakeDNSManager{rec: rec}) + + const events = 20 + for i := 0; i < events; i++ { + n.OnNetworkChanged(fmt.Sprintf("routes-%d", i)) + } + n.Close() + + require.Equal(t, events, rec.count(), "Close must not return before all queued events are delivered") + + n.OnNetworkChanged("after-close") + n.ApplyDns("after-close") + time.Sleep(50 * time.Millisecond) + assert.Equal(t, events, rec.count()) +} diff --git a/client/internal/updater/installer/doc.go b/client/internal/updater/installer/doc.go index 0a60454bb..aff0f24f7 100644 --- a/client/internal/updater/installer/doc.go +++ b/client/internal/updater/installer/doc.go @@ -37,23 +37,32 @@ // Updater Process (Setup): // // 1. Receives parameters from service via command-line arguments -// 2. Runs installer with appropriate silent/quiet flags: +// 2. Terminates the UI so the installer does not have to replace a locked image +// file, which would otherwise leave the install needing a reboot +// 3. Runs installer with appropriate silent/quiet flags: // - Windows EXE: installer.exe /S -// - Windows MSI: msiexec.exe /i installer.msi /quiet /qn /l*v msi.log +// - Windows MSI: msiexec.exe /i installer.msi /qn /norestart REBOOT=ReallySuppress /l*v msi.log // - macOS PKG: installer -pkg installer.pkg -target / // - macOS Homebrew: brew upgrade netbirdio/tap/netbird -// 3. Installer terminates daemon and UI processes -// 4. Installer replaces binaries with new version -// 5. Updater waits for installer to complete -// 6. Updater restarts daemon: +// 4. Installer terminates the daemon +// 5. Installer replaces binaries with new version +// 6. Updater waits for installer to complete. On Windows, MSI exit codes 3010 +// (ERROR_SUCCESS_REBOOT_REQUIRED) and 1641 (ERROR_SUCCESS_REBOOT_INITIATED) +// are a pending-reboot outcome, not a failure: the install succeeded, but +// some files are only replaced on the next restart (the reboot itself is +// suppressed via /norestart and REBOOT=ReallySuppress), and the flow +// continues as on success +// 7. Updater restarts daemon: // - Windows: netbird.exe service start // - macOS/Linux: netbird service start -// 7. Updater restarts UI: -// - Windows: Launches netbird-ui.exe as active console user using CreateProcessAsUser +// 8. Updater restarts UI: +// - Windows: Launches netbird-ui.exe using CreateProcessAsUser in every +// session it was terminated in, falling back to the active console session // - macOS: Uses launchctl asuser to launch NetBird.app for console user // - Linux: Not implemented (UI typically auto-starts) -// 8. Updater writes result.json with success/error status -// 9. Updater process exits +// 9. Updater writes result.json with success/error status (a pending reboot is +// recorded as success) +// 10. Updater process exits // // # Result Communication // @@ -100,6 +109,10 @@ // - Does NOT remove result.json (cleaned by ResultHandler after read) // - Does NOT remove msi.log (kept for debugging) // +// On Windows the updater copy is often still locked when the daemon it restarted +// runs cleanup, so removing it is retried briefly and otherwise left in place for +// the next update to overwrite rather than reported as a failure. +// // # Dry-Run Mode // // Dry-run mode allows testing the update process without actually installing: diff --git a/client/internal/updater/installer/installer_cleanup_windows_test.go b/client/internal/updater/installer/installer_cleanup_windows_test.go new file mode 100644 index 000000000..aab16dc93 --- /dev/null +++ b/client/internal/updater/installer/installer_cleanup_windows_test.go @@ -0,0 +1,67 @@ +package installer + +import ( + "os" + "path/filepath" + "testing" + "time" + + "golang.org/x/sys/windows" +) + +// lockFile opens path without FILE_SHARE_DELETE, so os.Remove fails the way it does +// while the updater process still holds its own image. +func lockFile(t *testing.T, path string) windows.Handle { + t.Helper() + + p, err := windows.UTF16PtrFromString(path) + if err != nil { + t.Fatalf("convert path: %v", err) + } + + handle, err := windows.CreateFile(p, windows.GENERIC_READ, windows.FILE_SHARE_READ, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + t.Fatalf("lock %s: %v", path, err) + } + return handle +} + +// releaseAfter closes the handle once the delay has passed, standing in for the +// updater process finally exiting. +func releaseAfter(t *testing.T, handle windows.Handle, delay time.Duration) { + t.Helper() + + released := make(chan struct{}) + t.Cleanup(func() { <-released }) + + go func() { + defer close(released) + time.Sleep(delay) + if err := windows.CloseHandle(handle); err != nil { + t.Errorf("close handle: %v", err) + } + }() +} + +// TestCleanUpInstallerFilesLockedUpdater covers the post-update cleanup race: the +// daemon cleans up at startup while the updater that restarted it is still exiting, +// so the updater image is locked and Windows refuses the delete. Cleanup must wait +// the lock out instead of reporting a failure and leaving the binary behind. +func TestCleanUpInstallerFilesLockedUpdater(t *testing.T) { + tempDir := t.TempDir() + path := filepath.Join(tempDir, updaterBinary) + if err := os.WriteFile(path, []byte("x"), 0o600); err != nil { + t.Fatalf("write updater: %v", err) + } + + releaseAfter(t, lockFile(t, path), 300*time.Millisecond) + + u := NewWithDir(tempDir) + if err := u.CleanUpInstallerFiles(); err != nil { + t.Fatalf("cleanup must tolerate a still-locked updater: %v", err) + } + + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("updater binary still present (stat err: %v)", err) + } +} diff --git a/client/internal/updater/installer/installer_common.go b/client/internal/updater/installer/installer_common.go index 8e44bee82..f917424b8 100644 --- a/client/internal/updater/installer/installer_common.go +++ b/client/internal/updater/installer/installer_common.go @@ -42,6 +42,9 @@ func NewWithDir(tempDir string) *Installer { // This will run by the original service process func (u *Installer) RunInstallation(ctx context.Context, targetVersion string) (err error) { resultHandler := NewResultHandler(u.tempDir) + if err := resultHandler.ClearStaleResult(); err != nil { + log.Warnf("clear stale installer result: %v", err) + } defer func() { if err != nil { @@ -149,8 +152,8 @@ func (u *Installer) CleanUpInstallerFiles() error { var merr *multierror.Error - if err := os.Remove(filepath.Join(u.tempDir, updaterBinary)); err != nil && !os.IsNotExist(err) { - merr = multierror.Append(merr, fmt.Errorf("failed to remove updater binary: %w", err)) + if err := removeUpdaterBinary(filepath.Join(u.tempDir, updaterBinary)); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove updater binary: %w", err)) } entries, err := os.ReadDir(u.tempDir) @@ -164,10 +167,16 @@ func (u *Installer) CleanUpInstallerFiles() error { } name := entry.Name() + // The updater copy is handled above; on Windows its name also matches the + // extension sweep, which would report the same file twice. + if strings.EqualFold(name, updaterBinary) { + continue + } + for _, ext := range binaryExtensions { if strings.HasSuffix(strings.ToLower(name), strings.ToLower(ext)) { if err := os.Remove(filepath.Join(u.tempDir, name)); err != nil { - merr = multierror.Append(merr, fmt.Errorf("failed to remove %s: %w", name, err)) + merr = multierror.Append(merr, fmt.Errorf("remove %s: %w", name, err)) } break } diff --git a/client/internal/updater/installer/installer_common_test.go b/client/internal/updater/installer/installer_common_test.go new file mode 100644 index 000000000..c1556c828 --- /dev/null +++ b/client/internal/updater/installer/installer_common_test.go @@ -0,0 +1,52 @@ +//go:build windows || darwin + +package installer + +import ( + "os" + "path/filepath" + "testing" +) + +// TestCleanUpInstallerFiles checks that cleanup removes the updater copy and the +// downloaded installer while leaving the logs and the result file for the daemon. +func TestCleanUpInstallerFiles(t *testing.T) { + tempDir := t.TempDir() + + installers := make([]string, 0, len(binaryExtensions)) + for _, ext := range binaryExtensions { + installers = append(installers, "netbird_installer."+ext) + } + + kept := []string{"installer.log", "result.json"} + + for _, name := range append(append([]string{updaterBinary}, installers...), kept...) { + if err := os.WriteFile(filepath.Join(tempDir, name), []byte("x"), 0o600); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + + u := NewWithDir(tempDir) + if err := u.CleanUpInstallerFiles(); err != nil { + t.Fatalf("CleanUpInstallerFiles: %v", err) + } + + for _, name := range append([]string{updaterBinary}, installers...) { + if _, err := os.Stat(filepath.Join(tempDir, name)); !os.IsNotExist(err) { + t.Errorf("%s was not removed (stat err: %v)", name, err) + } + } + + for _, name := range kept { + if _, err := os.Stat(filepath.Join(tempDir, name)); err != nil { + t.Errorf("%s should have been kept: %v", name, err) + } + } +} + +func TestCleanUpInstallerFilesMissingTempDir(t *testing.T) { + u := NewWithDir(filepath.Join(t.TempDir(), "does-not-exist")) + if err := u.CleanUpInstallerFiles(); err != nil { + t.Errorf("a missing temp dir is not a cleanup failure, got: %v", err) + } +} diff --git a/client/internal/updater/installer/installer_run_darwin.go b/client/internal/updater/installer/installer_run_darwin.go index 248a404aa..5650bc769 100644 --- a/client/internal/updater/installer/installer_run_darwin.go +++ b/client/internal/updater/installer/installer_run_darwin.go @@ -98,47 +98,44 @@ func (u *Installer) startDaemon(daemonFolder string) error { func (u *Installer) startUIAsUser() error { log.Infof("starting netbird-ui: %s", uiBinary) - // Get the current console user - cmd := exec.Command("stat", "-f", "%Su", "/dev/console") - output, err := cmd.Output() + username, err := consoleUser() if err != nil { - return fmt.Errorf("failed to get console user: %w", err) + return err } - username := strings.TrimSpace(string(output)) - if username == "" || username == "root" { - return fmt.Errorf("no active user session found") - } - - log.Infof("starting UI for user: %s", username) - - // Get user's UID userInfo, err := user.Lookup(username) if err != nil { - return fmt.Errorf("failed to lookup user %s: %w", username, err) + return fmt.Errorf("lookup user %s: %w", username, err) } - // Start the UI process as the console user using launchctl - // This ensures the app runs in the user's context with proper GUI access - launchCmd := exec.Command("launchctl", "asuser", userInfo.Uid, "open", "-a", uiBinary) + log.Infof("starting UI for user: %s (uid %s)", username, userInfo.Uid) + + launchCmd := exec.Command("launchctl", "asuser", userInfo.Uid, "sudo", "-u", username, "-H", "open", "-a", uiBinary) log.Infof("launchCmd: %s", launchCmd.String()) - // Set the user's home directory for proper macOS app behavior - launchCmd.Env = append(os.Environ(), "HOME="+userInfo.HomeDir) - log.Infof("set HOME environment variable: %s", userInfo.HomeDir) - if err := launchCmd.Start(); err != nil { - return fmt.Errorf("failed to start UI process: %w", err) - } - - // Release the process so it can run independently - if err := launchCmd.Process.Release(); err != nil { - log.Warnf("failed to release UI process: %v", err) + if err := launchCmd.Run(); err != nil { + return fmt.Errorf("run UI launch: %w", err) } log.Infof("netbird-ui started successfully for user %s", username) return nil } +func consoleUser() (string, error) { + output, err := exec.Command("stat", "-f", "%Su", "/dev/console").Output() + if err != nil { + return "", fmt.Errorf("get console user: %w", err) + } + + username := strings.TrimSpace(string(output)) + switch username { + case "", "root", "loginwindow", "_mbsetupuser": + return "", fmt.Errorf("no active GUI user session, console user: %q", username) + } + + return username, nil +} + func (u *Installer) installPkgFile(ctx context.Context, path string) error { log.Infof("installing pkg file: %s", path) diff --git a/client/internal/updater/installer/installer_run_windows.go b/client/internal/updater/installer/installer_run_windows.go index 70c7e32cf..b2ecf3299 100644 --- a/client/internal/updater/installer/installer_run_windows.go +++ b/client/internal/updater/installer/installer_run_windows.go @@ -2,6 +2,7 @@ package installer import ( "context" + "errors" "fmt" "os" "os/exec" @@ -22,6 +23,12 @@ const ( msiLogFile = "msi.log" + // ERROR_SUCCESS_REBOOT_REQUIRED and ERROR_SUCCESS_REBOOT_INITIATED + msiRebootRequired = 3010 + msiRebootInitiated = 1641 + + processExitWait = 10 * time.Second + msiDownloadURL = "https://github.com/netbirdio/netbird/releases/download/v%version/netbird_installer_%version_windows_%arch.msi" exeDownloadURL = "https://github.com/netbirdio/netbird/releases/download/v%version/netbird_installer_%version_windows_%arch.exe" ) @@ -38,6 +45,8 @@ var ( func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string, daemonFolder string) (resultErr error) { resultHandler := NewResultHandler(u.tempDir) + var uiSessions []uint32 + // Always ensure daemon and UI are restarted after setup defer func() { log.Infof("starting daemon back") @@ -46,7 +55,7 @@ func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string } log.Infof("starting UI back") - if err := u.startUIAsUser(daemonFolder); err != nil { + if err := u.startUI(daemonFolder, uiSessions); err != nil { log.Errorf("failed to start UI: %v", err) } @@ -75,6 +84,14 @@ func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string return } + // The UI holds an open handle on its own image. Left running, Restart Manager + // cannot shut it down (msiexec runs as LocalSystem here, the UI as the + // interactive user), so the MSI falls back to replacing the file on reboot and + // marks the install as restart-required. The deferred close-application action + // in the package runs too late to prevent that, it happens after + // InstallValidate has already registered the file as in use. + uiSessions = killUI() + var cmd *exec.Cmd switch installerType { case TypeExe: @@ -84,7 +101,9 @@ func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string installerDir := filepath.Dir(installerFile) logPath := filepath.Join(installerDir, msiLogFile) log.Infof("run msi installer: %s", installerFile) - cmd = exec.CommandContext(ctx, "msiexec.exe", "/i", filepath.Base(installerFile), "/quiet", "/qn", "/l*v", logPath) + // REBOOT=ReallySuppress: a silent install has no way to ask, so without it + // msiexec reboots the machine on its own if it decides one is needed. + cmd = exec.CommandContext(ctx, "msiexec.exe", "/i", filepath.Base(installerFile), "/qn", "/norestart", "REBOOT=ReallySuppress", "/l*v", logPath) } cmd.Dir = filepath.Dir(installerFile) @@ -95,9 +114,13 @@ func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string } log.Infof("installer started with PID %d", cmd.Process.Pid) - if resultErr = cmd.Wait(); resultErr != nil { - log.Errorf("installer process finished with error: %v", resultErr) - return + if err := cmd.Wait(); err != nil { + if !isRebootPending(err) { + resultErr = err + log.Errorf("installer process finished with error: %v", err) + return + } + log.Warnf("installer completed but reported a pending reboot, some files will be replaced on the next restart") } return nil @@ -117,16 +140,142 @@ func (u *Installer) startDaemon(daemonFolder string) error { return nil } -func (u *Installer) startUIAsUser(daemonFolder string) error { +func (u *Installer) startUI(daemonFolder string, sessionIDs []uint32) error { uiPath := filepath.Join(daemonFolder, uiName) log.Infof("starting netbird-ui: %s", uiPath) - // Get the active console session ID - sessionID := windows.WTSGetActiveConsoleSessionId() - if sessionID == 0xFFFFFFFF { - return fmt.Errorf("no active user session found") + if len(sessionIDs) == 0 { + sessionID := windows.WTSGetActiveConsoleSessionId() + if sessionID == 0xFFFFFFFF { + return fmt.Errorf("no active user session found") + } + sessionIDs = []uint32{sessionID} } + var errs []error + for _, sessionID := range sessionIDs { + if err := startUIInSession(uiPath, sessionID); err != nil { + errs = append(errs, fmt.Errorf("session %d: %w", sessionID, err)) + continue + } + log.Infof("netbird-ui started successfully in session %d", sessionID) + } + return errors.Join(errs...) +} + +// isRebootPending reports whether the installer exit code means it succeeded but +// left work for the next restart. The reboot itself is suppressed, so this is not +// a failure. +func isRebootPending(err error) bool { + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + return false + } + + switch exitErr.ExitCode() { + case msiRebootRequired, msiRebootInitiated: + return true + default: + return false + } +} + +// killUI terminates any running netbird-ui process and returns the IDs of the +// interactive sessions the terminated processes belonged to. Setup starts the +// UI again in those sessions once the installer is done. +func killUI() []uint32 { + pids, err := processIDsByName(uiName) + if err != nil { + log.Warnf("failed to look up %s processes: %v", uiName, err) + return nil + } + + sessions := make(map[uint32]struct{}) + for _, pid := range pids { + var sessionID uint32 + if err := windows.ProcessIdToSessionId(pid, &sessionID); err != nil { + log.Warnf("failed to look up session of %s (PID %d): %v", uiName, pid, err) + } + + if err := terminateProcess(pid); err != nil { + log.Warnf("failed to terminate %s (PID %d): %v", uiName, pid, err) + continue + } + log.Infof("terminated %s (PID %d) in session %d", uiName, pid, sessionID) + + if sessionID != 0 { + sessions[sessionID] = struct{}{} + } + } + + sessionIDs := make([]uint32, 0, len(sessions)) + for sessionID := range sessions { + sessionIDs = append(sessionIDs, sessionID) + } + return sessionIDs +} + +func processIDsByName(name string) ([]uint32, error) { + snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return nil, fmt.Errorf("create process snapshot: %w", err) + } + defer func() { + if err := windows.CloseHandle(snapshot); err != nil { + log.Warnf("failed to close process snapshot: %v", err) + } + }() + + var entry windows.ProcessEntry32 + entry.Size = uint32(unsafe.Sizeof(entry)) + + var pids []uint32 + for err = windows.Process32First(snapshot, &entry); err == nil; err = windows.Process32Next(snapshot, &entry) { + if strings.EqualFold(windows.UTF16ToString(entry.ExeFile[:]), name) { + pids = append(pids, entry.ProcessID) + } + } + if !errors.Is(err, windows.ERROR_NO_MORE_FILES) { + return nil, fmt.Errorf("enumerate processes: %w", err) + } + + return pids, nil +} + +func terminateProcess(pid uint32) error { + handle, err := windows.OpenProcess(windows.PROCESS_TERMINATE|windows.SYNCHRONIZE, false, pid) + if err != nil { + // The process may have exited between enumeration and now. + if errors.Is(err, windows.ERROR_INVALID_PARAMETER) { + return nil + } + return fmt.Errorf("open process: %w", err) + } + defer func() { + if err := windows.CloseHandle(handle); err != nil { + log.Warnf("failed to close process handle: %v", err) + } + }() + + if err := windows.TerminateProcess(handle, 0); err != nil { + return fmt.Errorf("terminate process: %w", err) + } + + // Wait for the handle to signal so the image file is released before the + // installer tries to overwrite it. A timeout is reported through the returned + // event, not through err, which stays nil unless the wait itself failed. + event, err := windows.WaitForSingleObject(handle, uint32(processExitWait.Milliseconds())) + if err != nil { + return fmt.Errorf("wait for process exit: %w", err) + } + if event != windows.WAIT_OBJECT_0 { + return fmt.Errorf("wait for process exit: unexpected wait result %#x", event) + } + + return nil +} + +func startUIInSession(uiPath string, sessionID uint32) error { // Get the user token for that session var userToken windows.Token err := windows.WTSQueryUserToken(sessionID, &userToken) @@ -158,6 +307,16 @@ func (u *Installer) startUIAsUser(daemonFolder string) error { } }() + var env *uint16 + if err := windows.CreateEnvironmentBlock(&env, primaryToken, false); err != nil { + return fmt.Errorf("create environment block: %w", err) + } + defer func() { + if err := windows.DestroyEnvironmentBlock(env); err != nil { + log.Warnf("failed to destroy environment block: %v", err) + } + }() + // Prepare startup info var si windows.StartupInfo si.Cb = uint32(unsafe.Sizeof(si)) @@ -180,7 +339,7 @@ func (u *Installer) startUIAsUser(daemonFolder string) error { nil, false, creationFlags, - nil, + env, nil, &si, &pi, @@ -197,7 +356,6 @@ func (u *Installer) startUIAsUser(daemonFolder string) error { log.Warnf("failed to close thread handle: %v", err) } - log.Infof("netbird-ui started successfully in session %d", sessionID) return nil } diff --git a/client/internal/updater/installer/installer_run_windows_test.go b/client/internal/updater/installer/installer_run_windows_test.go new file mode 100644 index 000000000..6a4540610 --- /dev/null +++ b/client/internal/updater/installer/installer_run_windows_test.go @@ -0,0 +1,108 @@ +package installer + +import ( + "errors" + "os/exec" + "slices" + "strconv" + "testing" +) + +// exitErrorWithCode returns a real *exec.ExitError carrying the given exit code. +func exitErrorWithCode(t *testing.T, code int) error { + t.Helper() + + err := exec.Command("cmd.exe", "/c", "exit "+strconv.Itoa(code)).Run() + if err == nil { + t.Fatalf("expected a non-zero exit for code %d", code) + } + return err +} + +func TestIsRebootPending(t *testing.T) { + tests := []struct { + name string + code int + want bool + }{ + {name: "reboot required", code: msiRebootRequired, want: true}, + {name: "reboot initiated", code: msiRebootInitiated, want: true}, + {name: "generic failure", code: 1603, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isRebootPending(exitErrorWithCode(t, tt.code)); got != tt.want { + t.Errorf("isRebootPending(exit %d) = %v, want %v", tt.code, got, tt.want) + } + }) + } +} + +// TestProcessIDsByNameAndTerminate spawns a long-running system process, finds it +// by name and terminates it, covering the path the updater uses to release the UI +// image file before the installer replaces it. +func TestProcessIDsByNameAndTerminate(t *testing.T) { + cmd := exec.Command("ping.exe", "-n", "60", "127.0.0.1") + if err := cmd.Start(); err != nil { + t.Fatalf("start ping: %v", err) + } + + pid := uint32(cmd.Process.Pid) + killed := false + t.Cleanup(func() { + if !killed { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + }) + + // Name matching must be case-insensitive: the snapshot reports PING.EXE. + pids, err := processIDsByName("ping.exe") + if err != nil { + t.Fatalf("processIDsByName: %v", err) + } + + if !slices.Contains(pids, pid) { + t.Fatalf("PID %d not among the ping.exe processes found: %v", pid, pids) + } + + if err := terminateProcess(pid); err != nil { + t.Fatalf("terminateProcess: %v", err) + } + killed = true + + // terminateProcess only returns once the handle has signalled, so the process + // is already gone and Wait must not block. It exits with the code passed to + // TerminateProcess, which is 0, so Wait reports no error. + if err := cmd.Wait(); err != nil { + t.Fatalf("wait for terminated ping: %v", err) + } + if !cmd.ProcessState.Exited() { + t.Error("process did not exit after terminateProcess") + } + + remaining, err := processIDsByName("ping.exe") + if err != nil { + t.Fatalf("processIDsByName after terminate: %v", err) + } + if slices.Contains(remaining, pid) { + t.Errorf("PID %d still listed after terminateProcess", pid) + } +} + +func TestProcessIDsByNameNoMatch(t *testing.T) { + pids, err := processIDsByName("netbird-nonexistent-process.exe") + if err != nil { + t.Fatalf("processIDsByName: %v", err) + } + if len(pids) != 0 { + t.Errorf("expected no matches, got %v", pids) + } +} + +func TestIsRebootPendingNonExitError(t *testing.T) { + if isRebootPending(errors.New("start installer: file not found")) { + t.Error("a non-exit error must not be treated as a pending reboot") + } +} diff --git a/client/internal/updater/installer/remove_updater_darwin.go b/client/internal/updater/installer/remove_updater_darwin.go new file mode 100644 index 000000000..4d4a0be60 --- /dev/null +++ b/client/internal/updater/installer/remove_updater_darwin.go @@ -0,0 +1,12 @@ +package installer + +import "os" + +// removeUpdaterBinary deletes the updater copy left in the temp dir. On darwin a +// running binary can be unlinked, so no retry is needed. +func removeUpdaterBinary(path string) error { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} diff --git a/client/internal/updater/installer/remove_updater_windows.go b/client/internal/updater/installer/remove_updater_windows.go new file mode 100644 index 000000000..0e23b1644 --- /dev/null +++ b/client/internal/updater/installer/remove_updater_windows.go @@ -0,0 +1,45 @@ +package installer + +import ( + "errors" + "os" + "time" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" +) + +const ( + // The updater is the process that restarted the daemon, so when the daemon + // cleans up at startup the updater is often still exiting and Windows refuses + // to delete its locked image. These bound how long cleanup waits for it. + updaterRemoveAttempts = 5 + updaterRemoveDelay = 200 * time.Millisecond +) + +// removeUpdaterBinary deletes the updater copy left in the temp dir, retrying +// while the still-exiting updater process holds its image. A binary that stays +// locked for the whole window is left in place and reported at info level: the +// next update overwrites it, so it is not worth failing cleanup over. +func removeUpdaterBinary(path string) error { + for attempt := 0; attempt < updaterRemoveAttempts; attempt++ { + if attempt > 0 { + time.Sleep(updaterRemoveDelay) + } + + err := os.Remove(path) + if err == nil || os.IsNotExist(err) { + return nil + } + if !isFileLocked(err) { + return err + } + } + + log.Infof("updater binary %s is still locked, leaving it for the next update to overwrite", path) + return nil +} + +func isFileLocked(err error) bool { + return errors.Is(err, windows.ERROR_ACCESS_DENIED) || errors.Is(err, windows.ERROR_SHARING_VIOLATION) +} diff --git a/client/internal/updater/installer/remove_updater_windows_test.go b/client/internal/updater/installer/remove_updater_windows_test.go new file mode 100644 index 000000000..09910d034 --- /dev/null +++ b/client/internal/updater/installer/remove_updater_windows_test.go @@ -0,0 +1,59 @@ +package installer + +import ( + "os" + "path/filepath" + "testing" + "time" + + "golang.org/x/sys/windows" +) + +func TestRemoveUpdaterBinaryRetriesWhileLocked(t *testing.T) { + path := filepath.Join(t.TempDir(), updaterBinary) + if err := os.WriteFile(path, []byte("x"), 0o600); err != nil { + t.Fatalf("write updater: %v", err) + } + + releaseAfter(t, lockFile(t, path), updaterRemoveDelay+50*time.Millisecond) + + if err := removeUpdaterBinary(path); err != nil { + t.Fatalf("removeUpdaterBinary: %v", err) + } + + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("updater binary still present (stat err: %v)", err) + } +} + +// TestRemoveUpdaterBinaryStaysLocked covers an updater that never releases its +// image within the retry window. Cleanup gives up quietly and leaves the file +// behind rather than reporting a failure. +func TestRemoveUpdaterBinaryStaysLocked(t *testing.T) { + path := filepath.Join(t.TempDir(), updaterBinary) + if err := os.WriteFile(path, []byte("x"), 0o600); err != nil { + t.Fatalf("write updater: %v", err) + } + + handle := lockFile(t, path) + t.Cleanup(func() { + if err := windows.CloseHandle(handle); err != nil { + t.Errorf("close handle: %v", err) + } + }) + + if err := removeUpdaterBinary(path); err != nil { + t.Fatalf("a permanently locked updater is not a cleanup failure, got: %v", err) + } + + if _, err := os.Stat(path); err != nil { + t.Errorf("locked updater binary should be left in place, stat: %v", err) + } +} + +func TestRemoveUpdaterBinaryMissingFile(t *testing.T) { + path := filepath.Join(t.TempDir(), updaterBinary) + if err := removeUpdaterBinary(path); err != nil { + t.Errorf("a missing updater binary is not a failure, got: %v", err) + } +} diff --git a/client/internal/updater/installer/result.go b/client/internal/updater/installer/result.go index 526c3eb53..55a0d8ac8 100644 --- a/client/internal/updater/installer/result.go +++ b/client/internal/updater/installer/result.go @@ -54,6 +54,12 @@ func (rh *ResultHandler) GetErrorResultReason() string { return "" } +// ClearStaleResult removes a result file left over from a previous installation +// attempt so result watchers cannot read an outdated outcome for the current attempt. +func (rh *ResultHandler) ClearStaleResult() error { + return rh.cleanup() +} + func (rh *ResultHandler) WriteSuccess() error { result := Result{ Success: true, diff --git a/client/internal/updater/manager.go b/client/internal/updater/manager.go index 7fc300739..1b69368d0 100644 --- a/client/internal/updater/manager.go +++ b/client/internal/updater/manager.go @@ -435,7 +435,7 @@ func (m *Manager) install(ctx context.Context, pendingVersion *v.Version) error } inst := installer.New() - if err := inst.RunInstallation(ctx, pendingVersion.String()); err != nil { + if err := inst.RunInstallation(ctx, pendingVersion.String()); err != nil { //nolint:staticcheck // always errors on platforms without an installer log.Errorf("error triggering update: %v", err) m.statusRecorder.PublishEvent( cProto.SystemEvent_ERROR, diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index 359a83556..96c747ae4 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -4,17 +4,19 @@ package NetBirdSDK import ( "context" + "errors" "fmt" "net/netip" "os" "sort" "strings" "sync" + "sync/atomic" "time" log "github.com/sirupsen/logrus" - "golang.org/x/exp/maps" + nbAnonymize "github.com/netbirdio/netbird/client/anonymize" "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/auth" "github.com/netbirdio/netbird/client/internal/debug" @@ -22,6 +24,7 @@ import ( "github.com/netbirdio/netbird/client/internal/listener" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/netevents" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/formatter" "github.com/netbirdio/netbird/route" @@ -29,10 +32,14 @@ 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 +) + +var errClientAlreadyRunning = errors.New("client is already running") // RouteListener export internal RouteListener for mobile type NetworkChangeListener interface { @@ -71,25 +78,41 @@ type Client struct { cacheDir string logFilePath string recorder *peer.Status - ctxCancel context.CancelFunc - ctxCancelLock *sync.Mutex deviceName string osName string osVersion string networkChangeListener listener.NetworkChangeListener onHostDnsFn func([]string) dnsManager dns.IosDnsManager - loginComplete bool - // preloadedConfig holds config loaded from JSON (used on tvOS where file writes are blocked) - preloadedConfig *profilemanager.Config + loginComplete atomic.Bool + // netMgr outlives engine restarts: it mirrors the OS connectivity, not + // the engine lifecycle. Run injects its state and sweeper into each new + // ConnectClient. + netMgr *netevents.Manager + preloadedConfigJSON atomic.Pointer[string] + // mdmSource holds the per-Client MDM policy source and its change + // detector as one unit. Set by SetMDMPolicyFetcher (called from the + // Swift side at extension init). Each Run passes the loader to the + // resolved Config so applyMDMPolicy picks up the active overlay. Nil + // means "MDM enforcement off for this Client". + mdmSource atomic.Pointer[mdmSource] + + // stateMu guards the run lifecycle as one unit: the cancel installed by + // the current run, the channel it closes on exit, and the state it + // published. One run at a time: startRun refuses a second Run while the + // previous one has not exited, and the platform serializes Stop before + // Start, so no generation tracking is needed. stateMu sync.RWMutex connectClient *internal.ConnectClient config *profilemanager.Config + runDone chan struct{} + ctxCancel context.CancelFunc } // NewClient instantiate a new Client func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osVersion string, osName string, networkChangeListener NetworkChangeListener, dnsManager DnsManager) *Client { + recorder := peer.NewRecorder("") return &Client{ cfgFile: cfgFile, stateFile: stateFile, @@ -98,80 +121,91 @@ func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osV deviceName: deviceName, osName: osName, osVersion: osVersion, - recorder: peer.NewRecorder(""), - ctxCancelLock: &sync.Mutex{}, + recorder: recorder, networkChangeListener: networkChangeListener, dnsManager: dnsManager, + netMgr: netevents.NewManager(recorder), } } -// SetConfigFromJSON loads config from a JSON string into memory. -// This is used on tvOS where file writes to App Group containers are blocked. -// When set, IsLoginRequired() and Run() will use this preloaded config instead of reading from file. +// SetConfigFromJSON stores the JSON config that later loads resolve instead of the config file (tvOS). func (c *Client) SetConfigFromJSON(jsonStr string) error { - cfg, err := profilemanager.ConfigFromJSON(jsonStr) - if err != nil { + if _, err := profilemanager.ConfigFromJSON(jsonStr); err != nil { log.Errorf("SetConfigFromJSON: failed to parse config JSON: %v", err) return err } - c.preloadedConfig = cfg + c.preloadedConfigJSON.Store(&jsonStr) log.Infof("SetConfigFromJSON: config loaded successfully from JSON") return nil } +func (c *Client) loadConfig(input profilemanager.ConfigInput) (*profilemanager.Config, error) { + var cfg *profilemanager.Config + var err error + if preloaded := c.preloadedConfigJSON.Load(); preloaded != nil { + cfg, err = profilemanager.ConfigFromJSON(*preloaded) + } else { + cfg, err = profilemanager.DirectUpdateOrCreateConfig(input) + } + if err != nil { + return nil, err + } + c.applyMDMOverlay(cfg) + return cfg, nil +} + // Run start the internal client. It is a blocker function func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error { exportEnvList(envList) log.Infof("Starting NetBird client") log.Debugf("Tunnel uses interface: %s", interfaceName) - var cfg *profilemanager.Config - var err error - - // Use preloaded config if available (tvOS where file writes are blocked) - if c.preloadedConfig != nil { - log.Infof("Run: using preloaded config from memory") - cfg = c.preloadedConfig - } else { - log.Infof("Run: loading config from file") - // Use DirectUpdateOrCreateConfig to avoid atomic file operations (temp file + rename) - // which are blocked by the tvOS sandbox in App Group containers - cfg, err = profilemanager.DirectUpdateOrCreateConfig(profilemanager.ConfigInput{ - ConfigPath: c.cfgFile, - StateFilePath: c.stateFile, - }) - if err != nil { - return err - } + cfg, err := c.loadConfig(profilemanager.ConfigInput{ + ConfigPath: c.cfgFile, + StateFilePath: c.stateFile, + }) + if err != nil { + return err } c.recorder.UpdateManagementAddress(cfg.ManagementURL.String()) c.recorder.UpdateRosenpass(cfg.RosenpassEnabled, cfg.RosenpassPermissive) - var ctx context.Context //nolint ctxWithValues := context.WithValue(context.Background(), system.DeviceNameCtxKey, c.deviceName) //nolint ctxWithValues = context.WithValue(ctxWithValues, system.OsNameCtxKey, c.osName) //nolint ctxWithValues = context.WithValue(ctxWithValues, system.OsVersionCtxKey, c.osVersion) - c.ctxCancelLock.Lock() - ctx, c.ctxCancel = context.WithCancel(ctxWithValues) - defer c.ctxCancel() - c.ctxCancelLock.Unlock() + runCtx, runCancel := context.WithCancel(ctxWithValues) + defer runCancel() - auth := NewAuthWithConfig(ctx, cfg) - err = auth.LoginSync() + done, err := c.startRun(runCancel) if err != nil { return err } + defer c.finishRun(done) + ctx := runCtx - log.Infof("Auth successful") + // No login pre-flight here. The engine's own loginToManagement (connect.go) performs + // the authoritative Login immediately before the first Sync, so a LoginSync() call at + // this point only duplicated it — costing two extra Login RPCs (IsLoginRequired + + // Login) on every engine start, since IsLoginRequired is itself a full Login RPC. + // + // Auth failures still reach the caller through the engine path: loginToManagement + // returns PermissionDenied, which marks the shared status recorder + // (MarkManagementDisconnected) and fires ClientStop → onDisconnected, where + // IsLoginRequiredCached() reports login-required. The error is also returned out of Run(). + // + // A pre-flight was also actively harmful when the server is unreachable: its 2-minute + // backoff blocked the start and then reported "login required" for what was really a + // timeout. The engine instead keeps retrying and recovers when the server returns. // todo do not throw error in case of cancelled context ctx = internal.CtxInitState(ctx) c.onHostDnsFn = func([]string) {} cfg.WgIface = interfaceName - connectClient := internal.NewConnectClient(ctx, cfg, c.recorder) + connectClient := internal.NewConnectClient(ctx, cfg, c.recorder, + internal.WithNetEvents(c.netMgr)) c.setState(cfg, connectClient) // Persist the latest sync response so DebugBundle can include the network // map. On iOS this is backed by disk to keep it out of the constrained @@ -180,40 +214,79 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error { return connectClient.RunOniOS(fd, c.networkChangeListener, c.dnsManager, c.stateFile, c.cacheDir, c.logFilePath) } -// Stop the internal client and free the resources +// SetNetworkAvailable feeds OS-reported network availability into the client +// (e.g. from NWPathMonitor). 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. Losing the last network also sweeps the +// registered connections, so the client does not keep reporting Connected +// over stale sockets with no network at all. +func (c *Client) SetNetworkAvailable(available bool) { + c.netMgr.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.netMgr.NotifyNetworkChange() +} + +// Stop cancels the running client and waits for the run loop to exit, so a +// caller that restarts immediately cannot race the outgoing teardown. func (c *Client) Stop() { - c.ctxCancelLock.Lock() - defer c.ctxCancelLock.Unlock() - if c.ctxCancel == nil { + done := c.cancelRun() + if done == nil { return } - c.ctxCancel() - c.setState(nil, nil) + select { + case <-done: + case <-time.After(stopRunWaitTimeout): + log.Warnf("Stop: timed out waiting for the run loop to exit") + } +} + +// StopWithoutWait cancels the running client without waiting for the run loop. +// Use it where the caller is on a deadline the wait could overrun, such as +// NEPacketTunnelProvider.stopTunnel, which iOS gives only a few seconds +// before it kills the extension. +func (c *Client) StopWithoutWait() { + c.cancelRun() +} + +func (c *Client) cancelRun() chan struct{} { + c.stateMu.RLock() + done := c.runDone + cancel := c.ctxCancel + c.stateMu.RUnlock() + + if cancel != nil { + cancel() + } + + return done } // DebugBundle generates a debug bundle, uploads it and returns the upload key. // It works with or without a running engine: when the engine is up it reuses // the live config, sync response and client metrics; otherwise it loads the -// config from disk (or the preloaded tvOS config). -func (c *Client) DebugBundle(anonymize bool) (string, error) { +// config from disk (or the preloaded tvOS config). anonymizeLevel is "default" +// or "strict"; strict also anonymizes internal IP ranges, peer names, and +// WireGuard public keys, and implies anonymize. +func (c *Client) DebugBundle(anonymize bool, anonymizeLevel string) (string, error) { cfg, cc := c.stateSnapshot() // If the engine hasn't been started, load config so we can reach management. if cfg == nil { - if c.preloadedConfig != nil { - cfg = c.preloadedConfig - } else { - var err error - // Use DirectUpdateOrCreateConfig to avoid atomic file operations - // (temp file + rename) blocked by the tvOS sandbox. - cfg, err = profilemanager.DirectUpdateOrCreateConfig(profilemanager.ConfigInput{ - ConfigPath: c.cfgFile, - StateFilePath: c.stateFile, - }) - if err != nil { - return "", fmt.Errorf("load config: %w", err) - } + var err error + cfg, err = c.loadConfig(profilemanager.ConfigInput{ + ConfigPath: c.cfgFile, + StateFilePath: c.stateFile, + }) + if err != nil { + return "", fmt.Errorf("load config: %w", err) } } @@ -233,6 +306,9 @@ func (c *Client) DebugBundle(anonymize bool) (string, error) { deps.SyncResponse = resp if e := cc.Engine(); e != nil { + deps.RefreshStatus = func() { + e.RunHealthProbes(context.Background(), true) + } if cm := e.GetClientMetrics(); cm != nil { deps.ClientMetrics = cm } @@ -243,6 +319,7 @@ func (c *Client) DebugBundle(anonymize bool) (string, error) { deps, debug.BundleConfig{ Anonymize: anonymize, + AnonymizeLevel: nbAnonymize.ParseLevel(anonymizeLevel), IncludeSystemInfo: true, }, ) @@ -260,7 +337,7 @@ func (c *Client) DebugBundle(anonymize bool) (string, error) { uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path) + key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path, false) if err != nil { return "", fmt.Errorf("upload debug bundle: %w", err) } @@ -312,7 +389,11 @@ func (c *Client) GetStatusDetails() *StatusDetails { // 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 @@ -331,40 +412,18 @@ func (c *Client) IsLoginRequiredCached() bool { } func (c *Client) IsLoginRequired() bool { - var ctx context.Context //nolint ctxWithValues := context.WithValue(context.Background(), system.DeviceNameCtxKey, c.deviceName) //nolint ctxWithValues = context.WithValue(ctxWithValues, system.OsNameCtxKey, c.osName) //nolint ctxWithValues = context.WithValue(ctxWithValues, system.OsVersionCtxKey, c.osVersion) - c.ctxCancelLock.Lock() - defer c.ctxCancelLock.Unlock() - ctx, c.ctxCancel = context.WithCancel(ctxWithValues) + ctx, cancel := context.WithCancel(ctxWithValues) + defer cancel() - var cfg *profilemanager.Config - var err error - - // Use preloaded config if available (tvOS where file writes are blocked) - if c.preloadedConfig != nil { - log.Infof("IsLoginRequired: using preloaded config from memory") - cfg = c.preloadedConfig - } else { - log.Infof("IsLoginRequired: loading config from file") - // Use DirectUpdateOrCreateConfig to avoid atomic file operations (temp file + rename) - // which are blocked by the tvOS sandbox in App Group containers - cfg, err = profilemanager.DirectUpdateOrCreateConfig(profilemanager.ConfigInput{ - ConfigPath: c.cfgFile, - }) - if err != nil { - log.Errorf("IsLoginRequired: failed to load config: %v", err) - // If we can't load config, assume login is required - return true - } - } - - if cfg == nil { - log.Errorf("IsLoginRequired: config is nil") + cfg, err := c.loadConfig(profilemanager.ConfigInput{ConfigPath: c.cfgFile}) + if err != nil { + log.Errorf("IsLoginRequired: failed to load config: %v", err) return true } @@ -388,17 +447,22 @@ func (c *Client) IsLoginRequired() bool { // loginForMobileAuthTimeout is the timeout for requesting auth info from the server const loginForMobileAuthTimeout = 30 * time.Second +const stopRunWaitTimeout = 20 * time.Second + func (c *Client) LoginForMobile() string { - var ctx context.Context //nolint ctxWithValues := context.WithValue(context.Background(), system.DeviceNameCtxKey, c.deviceName) //nolint ctxWithValues = context.WithValue(ctxWithValues, system.OsNameCtxKey, c.osName) //nolint ctxWithValues = context.WithValue(ctxWithValues, system.OsVersionCtxKey, c.osVersion) - c.ctxCancelLock.Lock() - defer c.ctxCancelLock.Unlock() - ctx, c.ctxCancel = context.WithCancel(ctxWithValues) + ctx, cancel := context.WithCancel(ctxWithValues) + loginDone := false + defer func() { + if !loginDone { + cancel() + } + }() // Use DirectUpdateOrCreateConfig to avoid atomic file operations (temp file + rename) // which are blocked by the tvOS sandbox in App Group containers @@ -409,6 +473,7 @@ func (c *Client) LoginForMobile() string { log.Errorf("LoginForMobile: failed to load config: %v", err) return fmt.Sprintf("failed to load config: %v", err) } + c.applyMDMOverlay(cfg) oAuthFlow, err := auth.NewOAuthFlow(ctx, cfg, false, false, "") if err != nil { @@ -425,7 +490,9 @@ func (c *Client) LoginForMobile() string { } // This could cause a potential race condition with loading the extension which need to be handled on swift side + loginDone = true go func() { + defer cancel() tokenInfo, err := oAuthFlow.WaitToken(ctx, flowInfo) if err != nil { log.Errorf("LoginForMobile: WaitToken failed: %v", err) @@ -442,18 +509,18 @@ func (c *Client) LoginForMobile() string { log.Errorf("LoginForMobile: Login failed: %v", err) return } - c.loginComplete = true + c.loginComplete.Store(true) }() return flowInfo.VerificationURIComplete } func (c *Client) IsLoginComplete() bool { - return c.loginComplete + return c.loginComplete.Load() } func (c *Client) ClearLoginComplete() { - c.loginComplete = false + c.loginComplete.Store(false) } func (c *Client) GetRoutesSelectionDetails() (*RoutesSelectionDetails, error) { @@ -634,23 +701,18 @@ func (c *Client) SelectRoute(id string) error { } routeManager := engine.GetRouteManager() - routeSelector := routeManager.GetRouteSelector() if id == "All" { log.Debugf("select all routes") - routeSelector.SelectAllRoutes() - } else { - log.Debugf("select route with id: %s", id) - routes := toNetIDs([]string{id}) - routesMap := routeManager.GetClientRoutesWithNetID() - routes = route.ExpandV6ExitPairs(routes, routesMap) - if err := routeSelector.SelectRoutes(routes, true, maps.Keys(routesMap)); err != nil { - log.Debugf("error when selecting routes: %s", err) - return fmt.Errorf("select routes: %w", err) - } + routeManager.SelectAllRoutes() + return nil } - routeManager.TriggerSelection(routeManager.GetClientRoutes()) - return nil + log.Debugf("select route with id: %s", id) + if err := routeManager.SelectRoutes(toNetIDs([]string{id}), true); err != nil { + log.Debugf("error when selecting routes: %s", err) + return err + } + return nil } func (c *Client) DeselectRoute(id string) error { @@ -664,31 +726,50 @@ func (c *Client) DeselectRoute(id string) error { } routeManager := engine.GetRouteManager() - routeSelector := routeManager.GetRouteSelector() if id == "All" { log.Debugf("deselect all routes") - routeSelector.DeselectAllRoutes() - } else { - log.Debugf("deselect route with id: %s", id) - routes := toNetIDs([]string{id}) - routesMap := routeManager.GetClientRoutesWithNetID() - routes = route.ExpandV6ExitPairs(routes, routesMap) - if err := routeSelector.DeselectRoutes(routes, maps.Keys(routesMap)); err != nil { - log.Debugf("error when deselecting routes: %s", err) - return fmt.Errorf("deselect routes: %w", err) - } + routeManager.DeselectAllRoutes() + return nil + } + + log.Debugf("deselect route with id: %s", id) + if err := routeManager.DeselectRoutes(toNetIDs([]string{id})); err != nil { + log.Debugf("error when deselecting routes: %s", err) + return err } - routeManager.TriggerSelection(routeManager.GetClientRoutes()) return nil } -// setState stores the running engine state so DebugBundle can reuse the live -// config and ConnectClient. It is cleared on Stop. -func (c *Client) setState(cfg *profilemanager.Config, cc *internal.ConnectClient) { +func (c *Client) startRun(cancel context.CancelFunc) (chan struct{}, error) { c.stateMu.Lock() defer c.stateMu.Unlock() + + if c.runDone != nil { + return nil, errClientAlreadyRunning + } + + done := make(chan struct{}) + c.runDone = done + c.ctxCancel = cancel + return done, nil +} + +func (c *Client) finishRun(done chan struct{}) { + c.stateMu.Lock() + c.connectClient = nil + c.config = nil + c.runDone = nil + c.ctxCancel = nil + c.stateMu.Unlock() + + close(done) +} + +func (c *Client) setState(cfg *profilemanager.Config, cc *internal.ConnectClient) { + c.stateMu.Lock() c.config = cfg c.connectClient = cc + c.stateMu.Unlock() } // stateSnapshot returns the current config and ConnectClient under the lock. diff --git a/client/ios/NetBirdSDK/connection_listener.go b/client/ios/NetBirdSDK/connection_listener.go new file mode 100644 index 000000000..d792537ba --- /dev/null +++ b/client/ios/NetBirdSDK/connection_listener.go @@ -0,0 +1,43 @@ +//go:build ios + +package NetBirdSDK + +import ( + "github.com/netbirdio/netbird/client/internal/peer" +) + +// Client state values, re-exported as basic constants so gomobile emits them +// into the generated 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 intentionally lacks OnStateChanged for now: adding a method to a gomobile +// interface breaks every Swift implementation, so the iOS app keeps building +// against the legacy per-state callbacks. A follow-up will extend it together +// with the app. +type ConnectionListener interface { + OnConnected() + OnDisconnected() + OnConnecting() + OnDisconnecting() + OnAddressChanged(string, string) + OnPeersListChanged(int) +} + +// connectionListenerAdapter adapts the gomobile-facing ConnectionListener to +// peer.Listener. +type connectionListenerAdapter struct { + ConnectionListener +} + +// OnStateChanged is dropped on iOS until the app adopts the state callback; +// the legacy per-state callbacks continue to fire. +func (a connectionListenerAdapter) OnStateChanged(peer.ClientState) {} diff --git a/client/ios/NetBirdSDK/login.go b/client/ios/NetBirdSDK/login.go index 432133999..0dfff620e 100644 --- a/client/ios/NetBirdSDK/login.go +++ b/client/ios/NetBirdSDK/login.go @@ -11,6 +11,8 @@ import ( "github.com/netbirdio/netbird/client/internal/auth" "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" + "github.com/netbirdio/netbird/client/mobile" "github.com/netbirdio/netbird/client/system" ) @@ -38,19 +40,46 @@ type Auth struct { ctx context.Context cancel context.CancelFunc config *profilemanager.Config + base *profilemanager.Config + policy *mdm.Policy cfgPath string } -// NewAuth instantiate Auth struct and validate the management URL -func NewAuth(cfgPath string, mgmURL string) (*Auth, error) { - inputCfg := profilemanager.ConfigInput{ - ManagementURL: mgmURL, +// NewAuth instantiate Auth struct and validate the management URL. +// Auth is constructed under the active MDM policy: the policy is overlaid on +// the resolved config so the login runs against the enforced values, while +// the persisted config keeps the caller-supplied ones; a caller-supplied +// management URL is ignored while MDM manages that key. A nil fetcher +// disables MDM enforcement. +func NewAuth(cfgPath string, mgmURL string, fetcher PolicyFetcher) (*Auth, error) { + policy := loaderFor(fetcher).Load() + inputCfg := profilemanager.ConfigInput{ConfigPath: cfgPath} + if _, managed := policy.GetString(mdm.KeyManagementURL); !managed { + inputCfg.ManagementURL = mgmURL } - cfg, err := profilemanager.CreateInMemoryConfig(inputCfg) + // Load the existing config when a config file is already present so an + // interactive re-login reuses the peer's persisted WireGuard private key + // (and thus its identity) instead of generating a fresh one. Generating a + // new key registers a brand-new peer on the management server on every + // re-auth (named after the fallback hostname). Only fall back to a fresh + // in-memory config for the first-time login when no config file exists yet. + // DirectUpdateOrCreateConfig uses non-atomic writes so it also works inside + // the tvOS App Group sandbox where atomic temp-file+rename is blocked. + var cfg *profilemanager.Config + var err error + if cfgPath != "" { + cfg, err = profilemanager.DirectUpdateOrCreateConfig(inputCfg) + } else { + cfg, err = profilemanager.CreateInMemoryConfig(inputCfg) + } if err != nil { return nil, err } + a := &Auth{policy: policy, cfgPath: cfgPath} + if err := a.setBaseConfig(cfg); err != nil { + return nil, err + } // Use a cancellable context so Stop() can abort an in-progress interactive // login. The PKCE flow's WaitToken blocks (and keeps its loopback HTTP server @@ -60,14 +89,8 @@ func NewAuth(cfgPath string, mgmURL string) (*Auth, error) { // process (decoupled from the network extension), so without this the server // lingers after the user dismisses the browser and the next connect stalls // trying to bind the same port. - ctx, cancel := context.WithCancel(context.Background()) - - return &Auth{ - ctx: ctx, - cancel: cancel, - config: cfg, - cfgPath: cfgPath, - }, nil + a.ctx, a.cancel = context.WithCancel(context.Background()) + return a, nil } // NewAuthWithConfig instantiate Auth based on existing config @@ -90,9 +113,7 @@ func (a *Auth) Stop() { } } -// SaveConfigIfSSOSupported test the connectivity with the management server by retrieving the server device flow info. -// If it returns a flow info than save the configuration and return true. If it gets a codes.NotFound, it means that SSO -// is not supported and returns false without saving the configuration. For other errors return false. +// SaveConfigIfSSOSupported reports whether the management server supports SSO; the config is already persisted by NewAuth. func (a *Auth) SaveConfigIfSSOSupported(listener SSOListener) { if listener == nil { log.Errorf("SaveConfigIfSSOSupported: listener is nil") @@ -120,17 +141,10 @@ func (a *Auth) saveConfigIfSSOSupported() (bool, error) { return false, fmt.Errorf("failed to check SSO support: %v", err) } - if !supportsSSO { - return false, nil - } - - // Use DirectWriteOutConfig to avoid atomic file operations (temp file + rename) - // which are blocked by the tvOS sandbox in App Group containers - err = profilemanager.DirectWriteOutConfig(a.cfgPath, a.config) - return true, err + return supportsSSO, nil } -// LoginWithSetupKeyAndSaveConfig test the connectivity with the management server with the setup key. +// LoginWithSetupKeyAndSaveConfig registers the peer with the setup key; the config is already persisted by NewAuth. func (a *Auth) LoginWithSetupKeyAndSaveConfig(resultListener ErrListener, setupKey string, deviceName string) { if resultListener == nil { log.Errorf("LoginWithSetupKeyAndSaveConfig: resultListener is nil") @@ -159,10 +173,7 @@ func (a *Auth) loginWithSetupKeyAndSaveConfig(setupKey string, deviceName string if err != nil { return fmt.Errorf("login failed: %v", err) } - - // Use DirectWriteOutConfig to avoid atomic file operations (temp file + rename) - // which are blocked by the tvOS sandbox in App Group containers - return profilemanager.DirectWriteOutConfig(a.cfgPath, a.config) + return nil } // LoginSync performs a synchronous login check without UI interaction @@ -207,17 +218,36 @@ func (a *Auth) Login(resultListener ErrListener, urlOpener URLOpener, forceDevic // LoginWithDeviceName performs interactive login with device authentication support // The deviceName parameter allows specifying a custom device name (required for tvOS) func (a *Auth) LoginWithDeviceName(resultListener ErrListener, urlOpener URLOpener, forceDeviceAuth bool, deviceName string) { + a.startLogin(resultListener, urlOpener, forceDeviceAuth, deviceName, false) +} + +// LoginInteractive performs the same interactive login as LoginWithDeviceName but skips the +// IsLoginRequired() pre-flight and goes straight to the browser / device-code flow. +// +// IsLoginRequired() is itself a full Login RPC against the management server, so when the +// caller has ALREADY established that login is required it is a pure duplicate. On iOS the +// main app decides to show the browser based on its own isLoginRequired() check and then +// calls straight into this method, so re-asking the server would add another Login RPC to +// every interactive login. +// +// Use LoginWithDeviceName when the auth state is unknown and a silent (browser-less) login +// must still be possible; use this when the browser is going to be shown regardless. +func (a *Auth) LoginInteractive(resultListener ErrListener, urlOpener URLOpener, forceDeviceAuth bool, deviceName string) { + a.startLogin(resultListener, urlOpener, forceDeviceAuth, deviceName, true) +} + +func (a *Auth) startLogin(resultListener ErrListener, urlOpener URLOpener, forceDeviceAuth bool, deviceName string, skipLoginCheck bool) { if resultListener == nil { - log.Errorf("LoginWithDeviceName: resultListener is nil") + log.Errorf("startLogin: resultListener is nil") return } if urlOpener == nil { - log.Errorf("LoginWithDeviceName: urlOpener is nil") + log.Errorf("startLogin: urlOpener is nil") resultListener.OnError(fmt.Errorf("urlOpener is nil")) return } go func() { - err := a.login(urlOpener, forceDeviceAuth, deviceName) + err := a.login(urlOpener, forceDeviceAuth, deviceName, skipLoginCheck) if err != nil { resultListener.OnError(err) } else { @@ -226,7 +256,7 @@ func (a *Auth) LoginWithDeviceName(resultListener ErrListener, urlOpener URLOpen }() } -func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName string) error { +func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName string, skipLoginCheck bool) error { // Create context with device name if provided ctx := a.ctx if deviceName != "" { @@ -240,19 +270,24 @@ func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName strin } defer authClient.Close() - // check if we need to generate JWT token - needsLogin, err := authClient.IsLoginRequired(ctx) - if err != nil { - return fmt.Errorf("failed to check login requirement: %v", err) + // check if we need to generate JWT token (skipped when the caller already knows) + needsLogin := true + if !skipLoginCheck { + needsLogin, err = authClient.IsLoginRequired(ctx) + if err != nil { + return fmt.Errorf("failed to check login requirement: %v", err) + } } jwtToken := "" + email := "" if needsLogin { tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, forceDeviceAuth) if err != nil { return fmt.Errorf("interactive sso login failed: %v", err) } jwtToken = tokenInfo.GetTokenToUse() + email = tokenInfo.Email } err, isAuthError := authClient.Login(ctx, "", jwtToken) @@ -264,16 +299,11 @@ func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName strin return fmt.Errorf("login failed: %v", err) } - // Save the config before notifying success to ensure persistence completes - // before the callback potentially triggers teardown on the Swift side. - // Note: This differs from Android which doesn't save config after login. - // On iOS/tvOS, we save here because: - // 1. The config may have been modified during login (e.g., new tokens) - // 2. On tvOS, the Network Extension context may be the only place with - // write permissions to the App Group container - if a.cfgPath != "" { - if err := profilemanager.DirectWriteOutConfig(a.cfgPath, a.config); err != nil { - log.Warnf("failed to save config after login: %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 := mobile.WriteProfileEmail(a.cfgPath, email); err != nil { + log.Warnf("failed to store profile account email: %v", err) } } @@ -283,10 +313,24 @@ func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName strin return nil } +// profileLoginHint returns the stored account email for the profile at cfgPath, +// so a re-login targets the account the profile already belongs to instead of +// whatever session the shared browser cookie jar happens to hold. +// +// 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 mobile.ReadProfileEmail(cfgPath) +} + const authInfoRequestTimeout = 30 * time.Second func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, forceDeviceAuth bool) (*auth.TokenInfo, error) { - oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, forceDeviceAuth) + oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, forceDeviceAuth, profileLoginHint(a.cfgPath)) if err != nil { return nil, fmt.Errorf("failed to get OAuth flow: %v", err) } @@ -313,23 +357,44 @@ func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener return &tokenInfo, nil } -// GetConfigJSON returns the current config as a JSON string. -// This can be used by the caller to persist the config via alternative storage -// mechanisms (e.g., UserDefaults on tvOS where file writes are blocked). +// GetConfigJSON returns the config without the MDM overlay as JSON, for persisting it outside the config file (tvOS). func (a *Auth) GetConfigJSON() (string, error) { - if a.config == nil { + cfg := a.base + if cfg == nil { + cfg = a.config + } + if cfg == nil { return "", fmt.Errorf("no config available") } - return profilemanager.ConfigToJSON(a.config) + return profilemanager.ConfigToJSON(cfg) } -// SetConfigFromJSON loads config from a JSON string. -// This can be used to restore config from alternative storage mechanisms. +// SetConfigFromJSON replaces the config from JSON; the MDM overlay is applied on top for the login. func (a *Auth) SetConfigFromJSON(jsonStr string) error { cfg, err := profilemanager.ConfigFromJSON(jsonStr) if err != nil { return err } - a.config = cfg + return a.setBaseConfig(cfg) +} + +func (a *Auth) setBaseConfig(base *profilemanager.Config) error { + overlaid, err := copyConfig(base) + if err != nil { + return err + } + if a.policy != nil { + overlaid.ApplyMDMPolicy(a.policy) + } + a.base = base + a.config = overlaid return nil } + +func copyConfig(cfg *profilemanager.Config) (*profilemanager.Config, error) { + raw, err := profilemanager.ConfigToJSON(cfg) + if err != nil { + return nil, err + } + return profilemanager.ConfigFromJSON(raw) +} diff --git a/client/ios/NetBirdSDK/mdm.go b/client/ios/NetBirdSDK/mdm.go new file mode 100644 index 000000000..93a31916c --- /dev/null +++ b/client/ios/NetBirdSDK/mdm.go @@ -0,0 +1,66 @@ +//go:build ios + +package NetBirdSDK + +import ( + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" +) + +// PolicyFetcher is implemented by the native layer to return the current +// managed configuration as a JSON-encoded object string; "" means no MDM +// source is present. +type PolicyFetcher interface { + FetchJSON() string +} + +type mdmSource struct { + loader *mdm.Loader + detector *mdm.ChangeDetector +} + +// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on +// this Client; passing nil disables MDM enforcement. +func (c *Client) SetMDMPolicyFetcher(p PolicyFetcher) { + loader := loaderFor(p) + c.mdmSource.Store(&mdmSource{loader: loader, detector: mdm.NewChangeDetector(loader)}) +} + +// HasMDMPolicyChanged re-reads the managed configuration and reports whether +// it changed since the last observation; call it from the native OS-change +// notification and restart the engine only on true. +func (c *Client) HasMDMPolicyChanged() bool { + src := c.mdmSource.Load() + if src == nil { + return false + } + return src.detector.Changed() +} + +// GetRestrictionsJSON returns the UI enforcement snapshot derived from the +// active MDM policy, in the JSON shape shared with the desktop frontend. +func (c *Client) GetRestrictionsJSON() (string, error) { + return mdm.BuildRestrictions(c.mdmLoader().Load()).JSON() +} + +func (c *Client) applyMDMOverlay(cfg *profilemanager.Config) { + loader := c.mdmLoader() + if cfg == nil || loader == nil { + return + } + cfg.ApplyMDMPolicy(loader.Load()) +} + +func (c *Client) mdmLoader() *mdm.Loader { + if src := c.mdmSource.Load(); src != nil { + return src.loader + } + return nil +} + +func loaderFor(p PolicyFetcher) *mdm.Loader { + if p == nil { + return mdm.NewJSONLoader(nil) + } + return mdm.NewJSONLoader(p.FetchJSON) +} diff --git a/client/ios/NetBirdSDK/preferences.go b/client/ios/NetBirdSDK/preferences.go index ed49ccddb..5297920a3 100644 --- a/client/ios/NetBirdSDK/preferences.go +++ b/client/ios/NetBirdSDK/preferences.go @@ -3,12 +3,16 @@ package NetBirdSDK import ( + "sync/atomic" + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" ) // Preferences export a subset of the internal config for gomobile type Preferences struct { configInput profilemanager.ConfigInput + mdmLoader atomic.Pointer[mdm.Loader] } // NewPreferences create new Preferences instance @@ -17,11 +21,30 @@ func NewPreferences(configPath string, stateFilePath string) *Preferences { ConfigPath: configPath, StateFilePath: stateFilePath, } - return &Preferences{ci} + return &Preferences{configInput: ci} +} + +// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on +// this Preferences instance; passing nil disables MDM enforcement. +func (p *Preferences) SetMDMPolicyFetcher(f PolicyFetcher) { + p.mdmLoader.Store(loaderFor(f)) +} + +// GetRestrictionsJSON returns the UI enforcement snapshot derived from the +// active MDM policy, in the JSON shape shared with the desktop frontend. +func (p *Preferences) GetRestrictionsJSON() (string, error) { + return mdm.BuildRestrictions(p.policy()).JSON() +} + +func (p *Preferences) policy() *mdm.Policy { + return p.mdmLoader.Load().Load() } // GetManagementURL read url from config file func (p *Preferences) GetManagementURL() (string, error) { + if v, ok := p.policy().GetString(mdm.KeyManagementURL); ok { + return mdm.CanonicalURL(v), nil + } if p.configInput.ManagementURL != "" { return p.configInput.ManagementURL, nil } @@ -30,7 +53,7 @@ func (p *Preferences) GetManagementURL() (string, error) { if err != nil { return "", err } - return cfg.ManagementURL.String(), err + return cfg.ManagementURL.String(), nil } // SetManagementURL store the given url and wait for commit @@ -56,17 +79,21 @@ func (p *Preferences) SetAdminURL(url string) { p.configInput.AdminURL = url } -// GetPreSharedKey read preshared key from config file -func (p *Preferences) GetPreSharedKey() (string, error) { +// HasPreSharedKey reports whether a pre-shared key is staged, persisted, or +// enforced by MDM; the key itself is never handed to the native layer. +func (p *Preferences) HasPreSharedKey() (bool, error) { + if _, ok := p.policy().GetString(mdm.KeyPreSharedKey); ok { + return true, nil + } if p.configInput.PreSharedKey != nil { - return *p.configInput.PreSharedKey, nil + return *p.configInput.PreSharedKey != "", nil } cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) if err != nil { - return "", err + return false, err } - return cfg.PreSharedKey, err + return cfg.PreSharedKey != "", nil } // SetPreSharedKey store the given key and wait for commit @@ -81,6 +108,9 @@ func (p *Preferences) SetRosenpassEnabled(enabled bool) { // GetRosenpassEnabled read rosenpass enabled from config file func (p *Preferences) GetRosenpassEnabled() (bool, error) { + if v, ok := p.policy().GetBool(mdm.KeyRosenpassEnabled); ok { + return v, nil + } if p.configInput.RosenpassEnabled != nil { return *p.configInput.RosenpassEnabled, nil } @@ -99,6 +129,9 @@ func (p *Preferences) SetRosenpassPermissive(permissive bool) { // GetRosenpassPermissive read rosenpass permissive from config file func (p *Preferences) GetRosenpassPermissive() (bool, error) { + if v, ok := p.policy().GetBool(mdm.KeyRosenpassPermissive); ok { + return v, nil + } if p.configInput.RosenpassPermissive != nil { return *p.configInput.RosenpassPermissive, nil } @@ -128,8 +161,34 @@ func (p *Preferences) SetDisableIPv6(disable bool) { p.configInput.DisableIPv6 = &disable } +// GetRemoteJobsAllowed reads the remote jobs opt-in from config file +func (p *Preferences) GetRemoteJobsAllowed() (bool, error) { + policy := p.policy() + if !policy.HasKey(mdm.KeyRemoteJobsAllowed) && p.configInput.RemoteJobsAllowed != nil { + return *p.configInput.RemoteJobsAllowed, nil + } + + cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + if err != nil { + return false, err + } + cfg.ApplyMDMPolicy(policy) + if cfg.RemoteJobsAllowed == nil { + return false, nil + } + return *cfg.RemoteJobsAllowed, nil +} + +// SetRemoteJobsAllowed stores the given value and waits for commit +func (p *Preferences) SetRemoteJobsAllowed(allowed bool) { + p.configInput.RemoteJobsAllowed = &allowed +} + // Commit write out the changes into config file func (p *Preferences) Commit() error { + if err := profilemanager.CheckMDMConflicts(p.configInput, p.policy()); err != nil { + return err + } // Use DirectUpdateOrCreateConfig to avoid atomic file operations (temp file + rename) // which are blocked by the tvOS sandbox in App Group containers _, err := profilemanager.DirectUpdateOrCreateConfig(p.configInput) diff --git a/client/ios/NetBirdSDK/preferences_test.go b/client/ios/NetBirdSDK/preferences_test.go index 5f75e7c9a..2382e123c 100644 --- a/client/ios/NetBirdSDK/preferences_test.go +++ b/client/ios/NetBirdSDK/preferences_test.go @@ -31,14 +31,13 @@ func TestPreferences_DefaultValues(t *testing.T) { t.Errorf("invalid default management url: %s", defaultVar) } - var preSharedKey string - preSharedKey, err = p.GetPreSharedKey() + hasPSK, err := p.HasPreSharedKey() if err != nil { - t.Fatalf("failed to read default preshared key: %s", err) + t.Fatalf("failed to read default preshared key presence: %s", err) } - if preSharedKey != "" { - t.Errorf("invalid preshared key: %s", preSharedKey) + if hasPSK { + t.Errorf("unexpected preshared key presence on fresh config") } } @@ -69,13 +68,13 @@ func TestPreferences_ReadUncommitedValues(t *testing.T) { } p.SetPreSharedKey(exampleString) - resp, err = p.GetPreSharedKey() + hasPSK, err := p.HasPreSharedKey() if err != nil { - t.Fatalf("failed to read preshared key: %s", err) + t.Fatalf("failed to read preshared key presence: %s", err) } - if resp != exampleString { - t.Errorf("unexpected preshared key: %s", resp) + if !hasPSK { + t.Errorf("expected preshared key presence after staging one") } } @@ -114,12 +113,12 @@ func TestPreferences_Commit(t *testing.T) { t.Errorf("unexpected management url: %s", resp) } - resp, err = p.GetPreSharedKey() + hasPSK, err := p.HasPreSharedKey() if err != nil { - t.Fatalf("failed to read preshared key: %s", err) + t.Fatalf("failed to read preshared key presence: %s", err) } - if resp != examplePresharedKey { - t.Errorf("unexpected preshared key: %s", resp) + if !hasPSK { + t.Errorf("expected preshared key presence after commit") } } diff --git a/client/ios/NetBirdSDK/profile_manager.go b/client/ios/NetBirdSDK/profile_manager.go new file mode 100644 index 000000000..df962e227 --- /dev/null +++ b/client/ios/NetBirdSDK/profile_manager.go @@ -0,0 +1,144 @@ +//go:build ios + +package NetBirdSDK + +import ( + "github.com/netbirdio/netbird/client/mobile" +) + +const ( + // iOS uses a single user context per app. + iosUsername = "ios" +) + +// Profile represents a profile for gomobile. +type Profile struct { + ID string + Name string + Email string + IsActive bool +} + +// ProfileArray wraps profiles for gomobile compatibility (gomobile cannot +// bind Go slices directly). +type ProfileArray struct { + items []*Profile +} + +// Length returns the number of profiles. +func (p *ProfileArray) Length() int { + return len(p.items) +} + +// Get returns the profile at index i, or nil if out of range. +func (p *ProfileArray) Get(i int) *Profile { + if i < 0 || i >= len(p.items) { + return nil + } + return p.items[i] +} + +// ProfileManager adapts the shared mobile profile manager (client/mobile) to +// gomobile-friendly types. See that package for the on-disk layout and +// semantics. +type ProfileManager struct { + impl *mobile.ProfileManager +} + +// NewProfileManager creates a new profile manager for iOS. configDir is the +// App Group shared container path that both the app and the network extension +// can reach. +func NewProfileManager(configDir string) *ProfileManager { + return &ProfileManager{impl: mobile.NewProfileManager(configDir, iosUsername)} +} + +// SetMDMPolicyFetcher registers the native-provided MDM policy fetcher on +// this ProfileManager; passing nil disables MDM enforcement. +func (pm *ProfileManager) SetMDMPolicyFetcher(f PolicyFetcher) { + pm.impl.SetMDMLoader(loaderFor(f)) +} + +// ListProfiles returns all available profiles, including the default profile, +// with their active status set. +func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) { + profiles, err := pm.impl.ListProfiles() + if err != nil { + return nil, err + } + + items := make([]*Profile, 0, len(profiles)) + for i := range profiles { + items = append(items, fromMobileProfile(&profiles[i])) + } + return &ProfileArray{items: items}, nil +} + +// GetActiveProfile returns the currently active profile. +func (pm *ProfileManager) GetActiveProfile() (*Profile, error) { + p, err := pm.impl.GetActiveProfile() + if err != nil { + return nil, err + } + return fromMobileProfile(p), nil +} + +// SwitchProfile records the given profile ID as the active profile. The caller +// must stop the VPN tunnel before switching. +func (pm *ProfileManager) SwitchProfile(id string) error { + return pm.impl.SwitchProfile(id) +} + +// AddProfile creates a new profile with the given display name and a +// generated ID. It returns the created profile so the caller learns the ID. +func (pm *ProfileManager) AddProfile(displayName string) (*Profile, error) { + p, err := pm.impl.AddProfile(displayName) + if err != nil { + return nil, err + } + return fromMobileProfile(p), nil +} + +// RenameProfile changes the display name of the profile identified by id. The +// on-disk filename (the ID) is left unchanged. +func (pm *ProfileManager) RenameProfile(id string, newName string) error { + return pm.impl.RenameProfile(id, newName) +} + +// LogoutProfile clears authentication data for a profile, forcing a re-login. +// The management URL and other settings are preserved. +func (pm *ProfileManager) LogoutProfile(id string) error { + return pm.impl.LogoutProfile(id) +} + +// RemoveProfile deletes a profile. The default profile and the active profile +// cannot be removed. +func (pm *ProfileManager) RemoveProfile(id string) error { + return pm.impl.RemoveProfile(id) +} + +// GetConfigPath returns the config file path for the given profile ID. Swift +// should call this instead of constructing paths itself. +func (pm *ProfileManager) GetConfigPath(id string) (string, error) { + return pm.impl.GetConfigPath(id) +} + +// GetStateFilePath returns the state file path for the given profile ID. +func (pm *ProfileManager) GetStateFilePath(id string) (string, error) { + return pm.impl.GetStateFilePath(id) +} + +// GetActiveConfigPath returns the config file path for the currently active +// profile. +func (pm *ProfileManager) GetActiveConfigPath() (string, error) { + return pm.impl.GetActiveConfigPath() +} + +// GetActiveStateFilePath returns the state file path for the currently active +// profile. +func (pm *ProfileManager) GetActiveStateFilePath() (string, error) { + return pm.impl.GetActiveStateFilePath() +} + +func fromMobileProfile(p *mobile.Profile) *Profile { + return &Profile{ID: p.ID, Name: p.Name, Email: p.Email, IsActive: p.IsActive} +} diff --git a/client/ios/NetBirdSDK/version.go b/client/ios/NetBirdSDK/version.go new file mode 100644 index 000000000..606ad18e2 --- /dev/null +++ b/client/ios/NetBirdSDK/version.go @@ -0,0 +1,12 @@ +//go:build ios + +package NetBirdSDK + +import "github.com/netbirdio/netbird/version" + +// GoClientVersion returns the NetBird Go client version that was baked into +// the framework at compile time via +// -ldflags "-X github.com/netbirdio/netbird/version.version=". +func GoClientVersion() string { + return version.NetbirdVersion() +} diff --git a/client/jobexec/executor.go b/client/jobexec/executor.go index e29cc8840..7c730f757 100644 --- a/client/jobexec/executor.go +++ b/client/jobexec/executor.go @@ -28,7 +28,11 @@ func NewExecutor() *Executor { return &Executor{} } -func (e *Executor) BundleJob(ctx context.Context, debugBundleDependencies debug.GeneratorDependencies, params debug.BundleConfig, waitForDuration time.Duration, mgmURL string) (string, error) { +func (e *Executor) BundleJob(ctx context.Context, debugBundleDependencies debug.GeneratorDependencies, params debug.BundleConfig, waitForDuration time.Duration, mgmURL, uploadURL string) (string, error) { + if uploadURL == "" { + uploadURL = types.DefaultBundleURL + } + if waitForDuration > MaxBundleWaitTime { log.Warnf("bundle wait time %v exceeds maximum %v, capping to maximum", waitForDuration, MaxBundleWaitTime) waitForDuration = MaxBundleWaitTime @@ -54,7 +58,7 @@ func (e *Executor) BundleJob(ctx context.Context, debugBundleDependencies debug. } }() - key, err := debug.UploadDebugBundle(ctx, types.DefaultBundleURL, mgmURL, path) + key, err := debug.UploadDebugBundle(ctx, uploadURL, mgmURL, path, false) if err != nil { log.Errorf("failed to upload debug bundle: %v", err) return "", fmt.Errorf("upload debug bundle: %w", err) diff --git a/client/mdm/canonical_loaders.go b/client/mdm/canonical_loaders.go index 29288b511..64a8093c3 100644 --- a/client/mdm/canonical_loaders.go +++ b/client/mdm/canonical_loaders.go @@ -27,9 +27,13 @@ var allKeys = []string{ KeyRosenpassEnabled, KeyRosenpassPermissive, KeyWireguardPort, + KeyEnableLocalMetrics, + KeyLocalMetricsAddress, KeySplitTunnelMode, KeySplitTunnelApps, KeyLazyConnection, + KeyRemoteJobsAllowed, + KeyBundleUploadURL, } // canonicalKey maps the lowercase form of a managed-config value name to diff --git a/client/mdm/canonical_loaders_test.go b/client/mdm/canonical_loaders_test.go new file mode 100644 index 000000000..330a15c47 --- /dev/null +++ b/client/mdm/canonical_loaders_test.go @@ -0,0 +1,52 @@ +//go:build windows || darwin + +package mdm + +import ( + "go/ast" + "go/parser" + "go/token" + "slices" + "strconv" + "testing" +) + +// TestAllKeysCoversEveryPolicyKey guards against the drift that adding a Key* +// constant without listing it in allKeys causes: the desktop loaders resolve +// value names through canonicalKey, so an unlisted key is silently discarded as +// unknown. policy.go is parsed rather than hand-mirrored so the test cannot go +// stale in the same way. +func TestAllKeysCoversEveryPolicyKey(t *testing.T) { + file, err := parser.ParseFile(token.NewFileSet(), "policy.go", nil, 0) + if err != nil { + t.Fatalf("parse policy.go: %v", err) + } + + for _, decl := range file.Decls { + gen, ok := decl.(*ast.GenDecl) + if !ok || gen.Tok != token.CONST { + continue + } + for _, spec := range gen.Specs { + value, ok := spec.(*ast.ValueSpec) + if !ok || len(value.Names) != 1 || len(value.Values) != 1 { + continue + } + name := value.Names[0].Name + if len(name) < 4 || name[:3] != "Key" { + continue + } + lit, ok := value.Values[0].(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + continue + } + key, err := strconv.Unquote(lit.Value) + if err != nil { + t.Fatalf("unquote %s: %v", name, err) + } + if !slices.Contains(allKeys, key) { + t.Errorf("%s (%q) is missing from allKeys, so the desktop loaders discard it as unknown", name, key) + } + } + } +} diff --git a/client/mdm/changedetector.go b/client/mdm/changedetector.go new file mode 100644 index 000000000..5c21ae355 --- /dev/null +++ b/client/mdm/changedetector.go @@ -0,0 +1,34 @@ +package mdm + +import "sync" + +// ChangeDetector tracks the last observed policy of a Loader so an +// OS-notification-driven caller can ask whether the managed configuration +// actually changed before restarting anything. +type ChangeDetector struct { + mu sync.Mutex + loader *Loader + prev *Policy +} + +// NewChangeDetector constructs a ChangeDetector seeded with the loader's +// current policy, so only a later change reports as changed. +func NewChangeDetector(loader *Loader) *ChangeDetector { + return &ChangeDetector{ + loader: loader, + prev: loader.Load(), + } +} + +// Changed re-reads the policy, logs the per-key diff, and reports whether it +// diverged from the last observation; the new snapshot becomes the baseline. +func (d *ChangeDetector) Changed() bool { + d.mu.Lock() + defer d.mu.Unlock() + curr := d.loader.Load() + if !policyChanged(d.prev, curr) { + return false + } + d.prev = curr + return true +} diff --git a/client/mdm/conflicts.go b/client/mdm/conflicts.go new file mode 100644 index 000000000..160212afb --- /dev/null +++ b/client/mdm/conflicts.go @@ -0,0 +1,116 @@ +package mdm + +import ( + "net/url" + + "github.com/netbirdio/netbird/util" +) + +// PreSharedKeyRedactedSentinel is the redaction mask returned in place of a +// real pre-shared key; an incoming value equal to it is a round-trip echo, +// never an override. +const PreSharedKeyRedactedSentinel = "**********" + +// ConflictCheck is a value-aware comparison between a single requested field +// and the corresponding MDM-enforced value. +type ConflictCheck struct { + Key string + Check func(*Policy) bool +} + +// ConflictBool builds a ConflictCheck for a boolean MDM key. +func ConflictBool(key string, p *bool) ConflictCheck { + return ConflictCheck{ + Key: key, + Check: func(pol *Policy) bool { + if p == nil { + return true + } + want, ok := pol.GetBool(key) + return ok && want == *p + }, + } +} + +// ConflictStringPtr builds a ConflictCheck for an optional string MDM key, +// where an explicit empty value is still a request to change the setting. A +// nil p means "field not set" (no override requested). +func ConflictStringPtr(key string, p *string) ConflictCheck { + return ConflictCheck{ + Key: key, + Check: func(pol *Policy) bool { + if p == nil { + return true + } + want, ok := pol.GetString(key) + return ok && want == *p + }, + } +} + +// ConflictURL builds a ConflictCheck for a URL-typed MDM key. The two sides are +// compared as the endpoints they address, not as strings: see +// util.SameServiceURL. +func ConflictURL(key, got string) ConflictCheck { + return ConflictCheck{ + Key: key, + Check: func(pol *Policy) bool { + if got == "" { + return true + } + want, ok := pol.GetString(key) + return ok && util.SameServiceURLStrings(want, got) + }, + } +} + +// ConflictInt64 builds a ConflictCheck for an integer MDM key. +func ConflictInt64(key string, p *int64) ConflictCheck { + return ConflictCheck{ + Key: key, + Check: func(pol *Policy) bool { + if p == nil { + return true + } + want, ok := pol.GetInt(key) + return ok && want == *p + }, + } +} + +// ResolveConflicts returns the names of keys whose requested value diverges +// from the policy-enforced value; keys the policy does not manage are skipped, +// a managed key without a Check counts as a conflict. +func ResolveConflicts(policy *Policy, checks []ConflictCheck) []string { + if policy.IsEmpty() { + return nil + } + var conflicts []string + for _, c := range checks { + if !policy.HasKey(c.Key) { + continue + } + if c.Check == nil || !c.Check(policy) { + conflicts = append(conflicts, c.Key) + } + } + return conflicts +} + +// CanonicalURL normalizes a service URL by appending the scheme default port +// when none is present; unparseable input is returned unchanged. +func CanonicalURL(s string) string { + u, err := url.ParseRequestURI(s) + if err != nil { + return s + } + if u.Port() == "" { + switch u.Scheme { + case "https": + u.Host += ":443" + case "http": + u.Host += ":80" + } + } + return u.String() +} diff --git a/client/mdm/conflicts_test.go b/client/mdm/conflicts_test.go new file mode 100644 index 000000000..d145ec103 --- /dev/null +++ b/client/mdm/conflicts_test.go @@ -0,0 +1,40 @@ +package mdm + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The same spellings, through the conflict check that decides whether a request +// is refused. An enforced URL restated in another spelling addresses the very +// server the policy names, so it must not be reported as a conflict. +func TestConflictURLComparesEndpoints(t *testing.T) { + policy := NewPolicy(map[string]any{KeyManagementURL: "https://mgmt.example.com"}) + require.True(t, policy.HasKey(KeyManagementURL)) + + for _, restated := range []string{ + "https://mgmt.example.com", + "https://mgmt.example.com:443", + "https://mgmt.example.com/", + "https://MGMT.example.com", + "https://mgmt.example.com:0443", + } { + conflicts := ResolveConflicts(policy, []ConflictCheck{ConflictURL(KeyManagementURL, restated)}) + assert.Empty(t, conflicts, "%q is the enforced endpoint written differently", restated) + } + + for _, diverging := range []string{ + "https://other.example.com", + "http://mgmt.example.com", + "https://mgmt.example.com:8443", + "https://mgmt.example.com/other", + } { + conflicts := ResolveConflicts(policy, []ConflictCheck{ConflictURL(KeyManagementURL, diverging)}) + assert.Equal(t, []string{KeyManagementURL}, conflicts, "%q addresses another endpoint", diverging) + } + + // An unset field is not a request to change anything. + assert.Empty(t, ResolveConflicts(policy, []ConflictCheck{ConflictURL(KeyManagementURL, "")})) +} diff --git a/client/mdm/jsonloader.go b/client/mdm/jsonloader.go new file mode 100644 index 000000000..7139b0e4f --- /dev/null +++ b/client/mdm/jsonloader.go @@ -0,0 +1,34 @@ +package mdm + +import ( + "encoding/json" + + log "github.com/sirupsen/logrus" +) + +type jsonPolicyFetcher struct { + fetch func() string +} + +// NewJSONLoader constructs a Loader whose policy source is a JSON-encoded +// object string, as produced by the mobile native layers; a nil fetch +// disables MDM enforcement. +func NewJSONLoader(fetch func() string) *Loader { + if fetch == nil { + return NewLoader(nil) + } + return NewLoader(&jsonPolicyFetcher{fetch: fetch}) +} + +func (f *jsonPolicyFetcher) Fetch() map[string]any { + raw := f.fetch() + if raw == "" { + return nil + } + var out map[string]any + if err := json.Unmarshal([]byte(raw), &out); err != nil { + log.Warnf("MDM mobile fetcher: invalid JSON payload from native: %v", err) + return nil + } + return out +} diff --git a/client/mdm/policy.go b/client/mdm/policy.go index 1feff28f8..638fa0d80 100644 --- a/client/mdm/policy.go +++ b/client/mdm/policy.go @@ -47,6 +47,8 @@ const ( KeyRosenpassEnabled = "rosenpassEnabled" KeyRosenpassPermissive = "rosenpassPermissive" KeyWireguardPort = "wireguardPort" + KeyEnableLocalMetrics = "enableLocalMetrics" + KeyLocalMetricsAddress = "localMetricsAddress" // Split tunnel is modeled as a single conceptual policy with two // registry/plist values. KeySplitTunnelMode is the discriminator @@ -60,6 +62,17 @@ const ( // the management feature flag. Read as a bool (native bool, or on/off, // true/false, 1/0, yes/no); absent = defer to management. KeyLazyConnection = "lazyConnection" + + // KeyRemoteJobsAllowed opts the peer into management-requested remote jobs + // (e.g. debug bundles). Read as a bool; absent = defer to the local config + // (which defaults to disabled). Stored on Config as RemoteJobsAllowed. + KeyRemoteJobsAllowed = "allowRemoteJobs" + + // KeyBundleUploadURL overrides the debug-bundle upload service URL for + // remote jobs, taking precedence over the management-supplied value. Read + // as a string; must be an https URL with a host. Absent = defer to the + // management-supplied URL (or the default upload server). + KeyBundleUploadURL = "debugBundleUploadURL" ) // Split-tunnel mode literals (KeySplitTunnelMode values). @@ -71,6 +84,8 @@ const ( // SecretKeys lists keys whose values must be redacted in logs. var SecretKeys = map[string]struct{}{ KeyPreSharedKey: {}, + // The upload URL can embed credentials or signed query tokens. + KeyBundleUploadURL: {}, } // boolStringLiterals enumerates the textual boolean encodings the @@ -104,16 +119,46 @@ func NewPolicy(values map[string]any) *Policy { return &Policy{values: values} } -// LoadPolicy reads the platform-native MDM configuration. Returns an -// empty (but non-nil) Policy when no source is present, the source is -// empty, or the platform is unsupported. +// PolicyFetcher supplies the managed configuration to a Loader. Mobile +// platforms (Android / iOS) implement it to push the OS-managed values +// into the Go runtime. On every platform a non-nil fetcher takes +// precedence over the native source, which is the test seam for the +// registry / plist loaders; a nil fetcher leaves the native source in +// charge, or disables MDM enforcement where there is none. +type PolicyFetcher interface { + Fetch() map[string]any +} + +// Loader is the DI-friendly entry point for reading the active MDM +// policy. Construct one at the daemon's lifecycle owner (Server on +// desktop, gomobile-exposed bridge on mobile) and pass it to anything +// that needs to read MDM state (the reload ticker, profilemanager's +// Config). Each callsite has the Loader handed in instead of looking +// up package-level state. +type Loader struct { + fetcher PolicyFetcher +} + +// NewLoader constructs a Loader. A non-nil fetcher takes precedence over +// the platform-native source; production desktop callers pass nil so the +// registry / plist stays authoritative. +func NewLoader(f PolicyFetcher) *Loader { + return &Loader{fetcher: f} +} + +// Load reads the platform-native MDM configuration and returns a +// Policy. Returns an empty (but non-nil) Policy when no source is +// present, the source is empty, or the platform is unsupported. // // Diagnostic logging differentiates the three states: // - source absent / unsupported platform: trace log only // - source present, zero keys: info "MDM enrolled (no managed keys)" // - source present, N keys: info "MDM enrolled with N managed keys: [...]" -func LoadPolicy() *Policy { - values, err := loadPlatformPolicy() +func (l *Loader) Load() *Policy { + if l == nil { + return &Policy{values: map[string]any{}} + } + values, err := l.loadPlatform() if err != nil { log.Tracef("MDM policy load: %v", err) return &Policy{values: map[string]any{}} @@ -190,6 +235,8 @@ func (p *Policy) GetBool(key string) (bool, bool) { return t != 0, true case int64: return t != 0, true + case float64: + return t != 0, true } return false, false } @@ -255,7 +302,7 @@ func (p *Policy) GetStringSlice(key string) ([]string, bool) { } // sortedKeys returns the keys of m as a deterministic, lexicographically -// sorted slice. Used internally by Policy.ManagedKeys and LoadPolicy's +// sorted slice. Used internally by Policy.ManagedKeys and Loader.Load's // diagnostic log line so callers see a stable key order across runs // regardless of Go's randomised map iteration. func sortedKeys(m map[string]any) []string { diff --git a/client/mdm/policy_darwin.go b/client/mdm/policy_darwin.go index 57aa1168c..4159f5b7e 100644 --- a/client/mdm/policy_darwin.go +++ b/client/mdm/policy_darwin.go @@ -25,8 +25,9 @@ import ( // writable plist, as a defense against tampered installs. const policyPlistPath = "/Library/Managed Preferences/io.netbird.client.plist" -// loadPlatformPolicy reads the MDM-managed configuration from the macOS -// managed-preferences plist at policyPlistPath. Returns: +// loadPlatform reads the MDM-managed configuration from the macOS +// managed-preferences plist at policyPlistPath, unless a fetcher was +// injected, in which case its values are returned instead. Returns: // - (nil, nil) when the plist is absent (device not MDM-enrolled for // NetBird, or admin has not yet pushed a payload) // - (map, nil) with N entries when N managed values are present @@ -39,13 +40,19 @@ const policyPlistPath = "/Library/Managed Preferences/io.netbird.client.plist" // skipped so a stray entry in the payload does not block startup. // Native plist value types map naturally onto the Policy accessor // expectations (GetString / GetBool / GetInt / GetStringSlice). -func loadPlatformPolicy() (map[string]any, error) { +func (l *Loader) loadPlatform() (map[string]any, error) { + // Honour the injected fetcher when present so tests (and any + // future non-macOS MDM channel) can short-circuit the plist read + // with a scripted policy. + if l != nil && l.fetcher != nil { + return l.fetcher.Fetch(), nil + } f, err := os.Open(policyPlistPath) if err != nil { if errors.Is(err, fs.ErrNotExist) { // Not enrolled for NetBird. Caller treats nil as // "no MDM source present". - //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy. + //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see Loader.Load. return nil, nil } return nil, fmt.Errorf("open %s: %w", policyPlistPath, err) diff --git a/client/mdm/policy_mobile.go b/client/mdm/policy_mobile.go index ec25d4bb1..2e25a2bb5 100644 --- a/client/mdm/policy_mobile.go +++ b/client/mdm/policy_mobile.go @@ -2,13 +2,14 @@ package mdm -// loadPlatformPolicy is unused on mobile: the native layer (Swift on iOS, -// Kotlin/Java on Android) reads the OS managed-config store and pushes the -// resulting dictionary in-process via a gomobile entry point that lands in -// Phase 5 / Phase 6. The stub keeps the package compilable for mobile -// builds and returns (nil, nil) — the platform-absent sentinel that -// LoadPolicy in policy.go treats as "no MDM source present". -func loadPlatformPolicy() (map[string]any, error) { - //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy. - return nil, nil +// loadPlatform reads the OS-managed configuration via the native +// PolicyFetcher injected at Loader construction. Returns +// (nil, nil) — the platform-absent sentinel that Loader.Load treats as +// "no MDM source present" — when no fetcher was provided. +func (l *Loader) loadPlatform() (map[string]any, error) { + if l == nil || l.fetcher == nil { + //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see Loader.Load. + return nil, nil + } + return l.fetcher.Fetch(), nil } diff --git a/client/mdm/policy_other.go b/client/mdm/policy_other.go index f4263afa2..5d0b17cfd 100644 --- a/client/mdm/policy_other.go +++ b/client/mdm/policy_other.go @@ -2,13 +2,17 @@ package mdm -// loadPlatformPolicy returns no policy on platforms without an MDM channel -// (Linux, FreeBSD). MDM enforcement is off and the client behaves as if -// the feature did not exist. Returns (nil, nil) — the platform-absent -// sentinel the caller (LoadPolicy in policy.go) treats as "no MDM -// source present"; an error here would just translate to the same -// outcome with an extra log line. -func loadPlatformPolicy() (map[string]any, error) { - //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy. +// loadPlatform reads the MDM policy on platforms without a native MDM +// channel (Linux, FreeBSD). When no fetcher was injected the policy is +// (nil, nil) — the platform-absent sentinel that Loader.Load treats as +// "MDM enforcement disabled". A non-nil fetcher takes precedence: it +// is the test-seam used by unit tests to inject a scripted policy +// without touching the OS, and the same hook supports any future +// non-mobile OS that grows an out-of-band MDM channel. +func (l *Loader) loadPlatform() (map[string]any, error) { + if l != nil && l.fetcher != nil { + return l.fetcher.Fetch(), nil + } + //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see Loader.Load. return nil, nil } diff --git a/client/mdm/policy_test.go b/client/mdm/policy_test.go index 6cbe69776..ea467f861 100644 --- a/client/mdm/policy_test.go +++ b/client/mdm/policy_test.go @@ -1,6 +1,7 @@ package mdm import ( + "runtime" "testing" "github.com/stretchr/testify/assert" @@ -95,7 +96,8 @@ func TestPolicy_GetBool(t *testing.T) { {"int64 nonzero", int64(2), true, true}, {"int64 zero", int64(0), false, true}, {"string garbage", "maybe", false, false}, - {"float unsupported", 1.0, false, false}, + {"float nonzero", 1.0, true, true}, + {"float zero", 0.0, false, true}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -155,10 +157,29 @@ func TestPolicy_GetStringSlice(t *testing.T) { }) } -func TestLoadPolicy_PlatformStubReturnsEmpty(t *testing.T) { - // loadPlatformPolicy is a stub on every OS for Phase 1. LoadPolicy must - // degrade gracefully and never return nil. - p := LoadPolicy() +// encoding/json decodes every JSON number into float64, so the mobile +// loaders never see int. +func TestJSONLoader_BoolFromNumber(t *testing.T) { + p := NewJSONLoader(func() string { return `{"blockInbound":1,"disableProfiles":0}` }).Load() + + got, ok := p.GetBool(KeyBlockInbound) + assert.True(t, ok) + assert.True(t, got) + + got, ok = p.GetBool(KeyDisableProfiles) + assert.True(t, ok) + assert.False(t, got) +} + +func TestLoader_NilFetcherReturnsEmpty(t *testing.T) { + // Loader.Load with no fetcher (desktop construction) must degrade + // gracefully and never return nil; on linux loadPlatform is a stub + // returning (nil, nil), and Load is expected to translate that + // into a non-nil empty Policy. + if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { + t.Skip("a nil fetcher reads the OS-managed policy on this platform") + } + p := NewLoader(nil).Load() require.NotNil(t, p) assert.True(t, p.IsEmpty()) assert.Empty(t, p.ManagedKeys()) diff --git a/client/mdm/policy_windows.go b/client/mdm/policy_windows.go index 0c2629f98..9363db436 100644 --- a/client/mdm/policy_windows.go +++ b/client/mdm/policy_windows.go @@ -61,8 +61,9 @@ func readRegistryValue(k registry.Key, name, canonical string, out map[string]an } } -// loadPlatformPolicy reads the MDM-managed configuration from the -// Windows registry under HKLM\Software\Policies\NetBird. Returns: +// loadPlatform reads the MDM-managed configuration from the Windows +// registry under HKLM\Software\Policies\NetBird, unless a fetcher was +// injected, in which case its values are returned instead. Returns: // - (nil, nil) when the key is absent (device not MDM-enrolled for NetBird) // - (map, nil) with N entries when N managed values are set (N may be 0) // - (nil, err) on open / enumerate registry errors @@ -70,12 +71,18 @@ func readRegistryValue(k registry.Key, name, canonical string, out map[string]an // Per-value type coercion + skip-on-error is delegated to // readRegistryValue. Unknown value names are logged and skipped so a // malformed deployment does not block startup. -func loadPlatformPolicy() (map[string]any, error) { +func (l *Loader) loadPlatform() (map[string]any, error) { + // Honour the injected fetcher when present so tests (and any + // future non-Windows MDM channel) can short-circuit the registry + // read with a scripted policy. + if l != nil && l.fetcher != nil { + return l.fetcher.Fetch(), nil + } k, err := registry.OpenKey(registry.LOCAL_MACHINE, policyRegistryPath, registry.QUERY_VALUE) if err != nil { if errors.Is(err, registry.ErrNotExist) { // Not enrolled. Caller treats nil as "no MDM source present". - //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy. + //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see Loader.Load. return nil, nil } return nil, fmt.Errorf("open %s: %w", policyRegistryPath, err) diff --git a/client/mdm/restrictions.go b/client/mdm/restrictions.go new file mode 100644 index 000000000..200756b78 --- /dev/null +++ b/client/mdm/restrictions.go @@ -0,0 +1,91 @@ +package mdm + +import "encoding/json" + +// Fields carries the per-key MDM enforcement state for a UI: value-typed +// fields hold the enforced value (nil pointer = not managed), boolean +// fields report that the key is managed. +type Fields struct { + ManagementURL string `json:"managementURL"` + PreSharedKey bool `json:"preSharedKey"` + WireguardPort bool `json:"wireguardPort"` + RosenpassEnabled bool `json:"rosenpassEnabled"` + RosenpassPermissive bool `json:"rosenpassPermissive"` + DisableClientRoutes bool `json:"disableClientRoutes"` + DisableServerRoutes bool `json:"disableServerRoutes"` + AllowServerSSH *bool `json:"allowServerSSH"` + DisableAutoConnect bool `json:"disableAutoConnect"` + DisableAutostart bool `json:"disableAutostart"` + BlockInbound bool `json:"blockInbound"` + DisableMetricsCollection bool `json:"disableMetricsCollection"` + SplitTunnelMode bool `json:"splitTunnelMode"` + SplitTunnelApps bool `json:"splitTunnelApps"` + RemoteJobsAllowed bool `json:"allowRemoteJobs"` + DisableAdvancedView *bool `json:"disableAdvancedView"` +} + +// Features carries the feature gates a UI must honor. +type Features struct { + DisableProfiles bool `json:"disableProfiles"` + DisableNetworks bool `json:"disableNetworks"` + DisableUpdateSettings bool `json:"disableUpdateSettings"` +} + +// Restrictions is the UI-facing enforcement snapshot; the JSON shape is +// shared by the desktop frontend and the mobile bridges. +type Restrictions struct { + MDM Fields `json:"mdm"` + Features Features `json:"features"` +} + +// BuildRestrictions derives the UI enforcement snapshot from the active +// policy. +func BuildRestrictions(policy *Policy) Restrictions { + var r Restrictions + if policy.IsEmpty() { + return r + } + + if v, ok := policy.GetString(KeyManagementURL); ok { + r.MDM.ManagementURL = CanonicalURL(v) + } + r.MDM.PreSharedKey = policy.HasKey(KeyPreSharedKey) + r.MDM.WireguardPort = policy.HasKey(KeyWireguardPort) + r.MDM.RosenpassEnabled = policy.HasKey(KeyRosenpassEnabled) + r.MDM.RosenpassPermissive = policy.HasKey(KeyRosenpassPermissive) + r.MDM.DisableClientRoutes = policy.HasKey(KeyDisableClientRoutes) + r.MDM.DisableServerRoutes = policy.HasKey(KeyDisableServerRoutes) + r.MDM.DisableAutoConnect = policy.HasKey(KeyDisableAutoConnect) + r.MDM.DisableAutostart = policy.HasKey(KeyDisableAutostart) + r.MDM.BlockInbound = policy.HasKey(KeyBlockInbound) + r.MDM.DisableMetricsCollection = policy.HasKey(KeyDisableMetricsCollection) + r.MDM.SplitTunnelMode = policy.HasKey(KeySplitTunnelMode) + r.MDM.SplitTunnelApps = policy.HasKey(KeySplitTunnelApps) + r.MDM.RemoteJobsAllowed = policy.HasKey(KeyRemoteJobsAllowed) + if v, ok := policy.GetBool(KeyAllowServerSSH); ok { + r.MDM.AllowServerSSH = &v + } + if v, ok := policy.GetBool(KeyDisableAdvancedView); ok { + r.MDM.DisableAdvancedView = &v + } + + if v, ok := policy.GetBool(KeyDisableProfiles); ok { + r.Features.DisableProfiles = v + } + if v, ok := policy.GetBool(KeyDisableNetworks); ok { + r.Features.DisableNetworks = v + } + if v, ok := policy.GetBool(KeyDisableUpdateSettings); ok { + r.Features.DisableUpdateSettings = v + } + return r +} + +// JSON renders the snapshot in the shared UI JSON shape. +func (r Restrictions) JSON() (string, error) { + b, err := json.Marshal(r) + if err != nil { + return "", err + } + return string(b), nil +} diff --git a/client/mdm/ticker.go b/client/mdm/ticker.go index abd6ae233..be8fdcce7 100644 --- a/client/mdm/ticker.go +++ b/client/mdm/ticker.go @@ -15,33 +15,33 @@ import ( // instead, hence anticipating the ticker mechanism entirely. const DefaultReloadInterval = 1 * time.Minute -// policyLoader is the indirection through which the ticker reads the -// OS-native policy, both for the initial observation and on every tick. -// Production points it at LoadPolicy; tests in this package override it to -// feed a scripted sequence of policies without touching the real OS store. -var policyLoader = LoadPolicy - -// Ticker periodically re-reads the OS-native MDM policy via LoadPolicy and -// invokes the onChange callback (supplied to Run) whenever the observed -// Policy diverges from the last observation (added / removed / changed -// keys). Launch with Run from a goroutine; cancel the supplied context -// to stop. +// Ticker periodically re-reads the OS-native MDM policy via the +// injected Loader and invokes the onChange callback (supplied to Run) +// whenever the observed Policy diverges from the last observation +// (added / removed / changed keys). Launch with Run from a goroutine; +// cancel the supplied context to stop. type Ticker struct { interval time.Duration + loader *Loader prev *Policy } // NewTicker constructs a Ticker that will re-read the OS-native policy -// every reloadInterval once Run is called. -// The initial snapshot is populated by calling policyLoader at +// every reloadInterval once Run is called. The Loader is injected so +// the ticker doesn't depend on any package-level state — production +// passes the daemon-owned Loader, tests pass a fake Loader (built with +// a fake PolicyFetcher). +// +// The initial snapshot is populated by calling loader.Load() at // construction time so the first tick only fires // onChange when the policy actually changed since boot — without // this baseline the first tick would report every currently-managed // key as "added" and trigger a spurious engine restart. -func NewTicker(reloadInterval time.Duration) *Ticker { +func NewTicker(reloadInterval time.Duration, loader *Loader) *Ticker { return &Ticker{ interval: reloadInterval, - prev: policyLoader(), + loader: loader, + prev: loader.Load(), } } @@ -58,13 +58,10 @@ func (t *Ticker) Run(ctx context.Context, onChange func(prev, curr *Policy) erro log.Info("MDM policy reload ticker stopped") return case <-tk.C: - curr := policyLoader() - if policiesEqual(t.prev, curr) { + curr := t.loader.Load() + if !policyChanged(t.prev, curr) { continue } - added, removed, changed := diffPolicies(t.prev, curr) - log.Infof("MDM policy changed: added=%v removed=%v changed=%v", - added, removed, changed) prev := t.prev if err := onChange(prev, curr); err != nil { log.Errorf("MDM policy change handler failed (retrying in 1 minute): %v", err) @@ -127,3 +124,12 @@ func mapOf(p *Policy) map[string]any { } return out } + +func policyChanged(prev, curr *Policy) bool { + if policiesEqual(prev, curr) { + return false + } + added, removed, changed := diffPolicies(prev, curr) + log.Infof("MDM policy changed: added=%v removed=%v changed=%v", added, removed, changed) + return true +} diff --git a/client/mdm/ticker_test.go b/client/mdm/ticker_test.go index 17f3cfc2f..29e48e728 100644 --- a/client/mdm/ticker_test.go +++ b/client/mdm/ticker_test.go @@ -13,28 +13,40 @@ import ( // testReloadInterval for speeding up the ticker cadence under `go test` const testReloadInterval = 1 * time.Second -// withPolicyLoader overrides the package-level policyLoader for the duration -// of the test so the ticker observes a scripted policy instead of the real -// OS-native store. The original loader is restored on cleanup. -func withPolicyLoader(t *testing.T, fn func() *Policy) { - t.Helper() - prev := policyLoader - policyLoader = fn - t.Cleanup(func() { policyLoader = prev }) +// fakePolicyFetcher implements PolicyFetcher returning a scripted +// policy map. Goroutine-safe so the test can mutate the script while +// the ticker is observing it. +type fakePolicyFetcher struct { + mu sync.Mutex + values map[string]any +} + +func (f *fakePolicyFetcher) Fetch() map[string]any { + f.mu.Lock() + defer f.mu.Unlock() + if f.values == nil { + return nil + } + out := make(map[string]any, len(f.values)) + for k, v := range f.values { + out[k] = v + } + return out +} + +func (f *fakePolicyFetcher) set(values map[string]any) { + f.mu.Lock() + defer f.mu.Unlock() + f.values = values } func TestTicker_FiresOnChangeWithDelta(t *testing.T) { - var mu sync.Mutex - current := NewPolicy(nil) // initial observation: empty (no enforcement) - withPolicyLoader(t, func() *Policy { - mu.Lock() - defer mu.Unlock() - return current - }) + fetcher := &fakePolicyFetcher{} // initial observation: empty (no enforcement) + loader := NewLoader(fetcher) type change struct{ prev, curr *Policy } changes := make(chan change, 1) - tk := NewTicker(testReloadInterval) + tk := NewTicker(testReloadInterval, loader) require.Equal(t, testReloadInterval, tk.interval) ctx, cancel := context.WithCancel(context.Background()) @@ -49,15 +61,13 @@ func TestTicker_FiresOnChangeWithDelta(t *testing.T) { }) close(done) }() - // Stop Run and wait for it to exit before returning, so the policyLoader - // restore in t.Cleanup can't race the ticker goroutine still reading it. + // Stop Run and wait for it to exit before returning, so the test + // goroutine doesn't race the still-running ticker. defer func() { cancel(); <-done }() - // Flip the OS-observed policy from empty to one managed key. The next - // tick must detect the diff and invoke onChange. - mu.Lock() - current = NewPolicy(map[string]any{KeyManagementURL: "https://mdm.example.com:443"}) - mu.Unlock() + // Flip the OS-observed policy from empty to one managed key. The + // next tick must detect the diff and invoke onChange. + fetcher.set(map[string]any{KeyManagementURL: "https://mdm.example.com:443"}) select { case c := <-changes: @@ -69,12 +79,11 @@ func TestTicker_FiresOnChangeWithDelta(t *testing.T) { } func TestTicker_NoCallbackWhenPolicyUnchanged(t *testing.T) { - withPolicyLoader(t, func() *Policy { - return NewPolicy(map[string]any{KeyBlockInbound: true}) - }) + fetcher := &fakePolicyFetcher{values: map[string]any{KeyBlockInbound: true}} + loader := NewLoader(fetcher) fired := make(chan struct{}, 1) - tk := NewTicker(testReloadInterval) + tk := NewTicker(testReloadInterval, loader) ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) @@ -90,8 +99,8 @@ func TestTicker_NoCallbackWhenPolicyUnchanged(t *testing.T) { }() defer func() { cancel(); <-done }() - // Over ~2 ticks at the 1s test cadence the policy never changes, so the - // diff guard must suppress the callback entirely. + // Over ~2 ticks at the 1s test cadence the policy never changes, + // so the diff guard must suppress the callback entirely. select { case <-fired: t.Fatal("onChange fired despite an unchanged policy") diff --git a/client/mobile/profile_manager.go b/client/mobile/profile_manager.go new file mode 100644 index 000000000..348b7253b --- /dev/null +++ b/client/mobile/profile_manager.go @@ -0,0 +1,336 @@ +// Package mobile holds the profile manager implementation shared by the +// Android and iOS gomobile bindings. The platform packages (client/android, +// client/ios/NetBirdSDK) only adapt this API to gomobile-friendly types. +package mobile + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" +) + +const ( + // Config filename of the default profile, stored at the configDir root. + // Both platforms use netbird.cfg (matching the desktop netbird.cfg rather + // than default.json); the app-side path constants must match. + defaultConfigFilename = "netbird.cfg" + // Subdirectory of configDir holding non-default profiles. + profilesSubdir = "profiles" +) + +// ErrProfilesDisabled marks a profile mutation rejected by MDM policy. +var ErrProfilesDisabled = errors.New("profile management is disabled by MDM policy") + +/* + +/ ← app-writable config root +├── netbird.cfg ← Default profile config +├── netbird.account.json ← Default profile account email (see profile_state.go) +├── state.json ← Default profile state +├── active_profile.json ← Active profile tracker (JSON with ID + Username) +└── profiles/ ← Subdirectory for non-default profiles + ├── 4c5f5c8198c3989cffb5b5394f5a7ae0.json ← Profile config (filename = ID) + ├── 4c5f5c8198c3989cffb5b5394f5a7ae0.state.json ← Profile state + ├── 4c5f5c8198c3989cffb5b5394f5a7ae0.account.json ← Profile account email + └── 4c5f5c8198c3989cffb5b5394f5a7ae0.prefs.json ← Profile preferences +*/ + +// Profile is the platform-independent profile view handed to the bindings. +type Profile struct { + 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 +} + +// ProfileManager manages profiles for the mobile platforms. It wraps the +// internal profilemanager.ServiceManager with mobile-specific path handling. +// All profile identity is ID-based; the human-readable name lives inside the +// profile config's Name field. +type ProfileManager struct { + configDir string + username string + serviceMgr *profilemanager.ServiceManager + mdmLoader *mdm.Loader +} + +// NewProfileManager creates a profile manager rooted at configDir, the +// app-writable directory that every process of the app can reach. username is +// the platform's fixed single-user context (a non-empty username is required +// by ServiceManager for non-default profiles). +func NewProfileManager(configDir, username string) *ProfileManager { + // The default profile is stored in the root configDir, not under profiles/. + defaultConfigPath := filepath.Join(configDir, defaultConfigFilename) + + // Point the package globals at the app-provided directory, overriding the + // desktop defaults set in profilemanager's init(). + profilemanager.DefaultConfigPathDir = configDir + profilemanager.DefaultConfigPath = defaultConfigPath + profilemanager.ActiveProfileStatePath = filepath.Join(configDir, "active_profile.json") + + // Non-default profiles live in the profiles/ subdirectory. Passing it + // explicitly avoids touching the global config-dir override. + profilesDir := filepath.Join(configDir, profilesSubdir) + serviceMgr := profilemanager.NewServiceManagerWithProfilesDir(defaultConfigPath, profilesDir) + + return &ProfileManager{ + configDir: configDir, + username: username, + serviceMgr: serviceMgr, + } +} + +// ListProfiles returns all available profiles, including the default profile, +// with their active status set. +func (pm *ProfileManager) ListProfiles() ([]Profile, error) { + internalProfiles, err := pm.serviceMgr.ListProfiles(pm.username) + if err != nil { + return nil, fmt.Errorf("list profiles: %w", err) + } + + profiles := make([]Profile, 0, len(internalProfiles)) + for _, p := range internalProfiles { + profiles = append(profiles, Profile{ + ID: p.ID.String(), + Name: p.Name, + Email: pm.profileEmail(p.ID.String()), + IsActive: p.IsActive, + }) + } + + return profiles, nil +} + +// GetActiveProfile returns the currently active profile, resolving its ID to +// the full profile so callers get the real display name. +func (pm *ProfileManager) GetActiveProfile() (*Profile, error) { + activeState, err := pm.serviceMgr.GetActiveProfileState() + if err != nil { + return nil, fmt.Errorf("get active profile: %w", err) + } + + prof, err := pm.serviceMgr.ResolveProfile(activeState.ID.String(), pm.username) + if err != nil { + return nil, fmt.Errorf("resolve active profile %q: %w", activeState.ID, err) + } + return &Profile{ + ID: prof.ID.String(), + Name: prof.Name, + Email: pm.profileEmail(prof.ID.String()), + IsActive: true, + }, nil +} + +// SwitchProfile records the given profile ID as the active profile. The caller +// must stop the VPN tunnel before switching. +func (pm *ProfileManager) SwitchProfile(id string) error { + if err := pm.checkProfilesAllowed(); err != nil { + return err + } + if err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{ + ID: profilemanager.ID(id), + Username: pm.username, + }); err != nil { + return fmt.Errorf("switch profile: %w", err) + } + + log.Infof("switched to profile: %s", id) + return nil +} + +// AddProfile creates a new profile with the given display name and a +// generated ID. It returns the created profile so the caller learns the ID. +func (pm *ProfileManager) AddProfile(displayName string) (*Profile, error) { + if err := pm.checkProfilesAllowed(); err != nil { + return nil, err + } + profile, err := pm.serviceMgr.AddProfile(displayName, pm.username) + if err != nil { + return nil, fmt.Errorf("add profile: %w", err) + } + + log.Infof("created new profile: %s", profile.ID) + return &Profile{ID: profile.ID.String(), Name: profile.Name, IsActive: false}, nil +} + +// RenameProfile changes the display name of the profile identified by id. The +// on-disk filename (the ID) is left unchanged. +func (pm *ProfileManager) RenameProfile(id string, newName string) error { + if err := pm.checkProfilesAllowed(); err != nil { + return err + } + if err := pm.serviceMgr.RenameProfile(profilemanager.ID(id), pm.username, newName); err != nil { + return fmt.Errorf("rename profile: %w", err) + } + + log.Infof("renamed profile %s to %q", id, newName) + return nil +} + +// LogoutProfile clears authentication data for a profile by removing its +// private key and SSH key from the config, forcing a re-login. The management +// URL and other settings are preserved. +func (pm *ProfileManager) LogoutProfile(id string) error { + if err := pm.checkProfileLogoutAllowed(id); err != nil { + return err + } + configPath, err := pm.getProfileConfigPath(id) + if err != nil { + return err + } + + if _, err := os.Stat(configPath); os.IsNotExist(err) { + return fmt.Errorf("profile %q does not exist", id) + } + + config, err := profilemanager.ReadConfig(configPath) + if err != nil { + return fmt.Errorf("read profile config: %w", err) + } + + config.PrivateKey = "" + config.SSHKey = "" + + if err := profilemanager.WriteOutConfig(configPath, config); err != nil { + return fmt.Errorf("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 +} + +// RemoveProfile deletes a profile. The default profile and the active profile +// cannot be removed. +func (pm *ProfileManager) RemoveProfile(id string) error { + if err := pm.checkProfilesAllowed(); err != nil { + return err + } + configPath, err := pm.getProfileConfigPath(id) + if err != nil { + return err + } + + if err := pm.serviceMgr.RemoveProfile(profilemanager.ID(id), pm.username); err != nil { + return fmt.Errorf("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 +} + +// ProfilePrefs returns the namespaced per-profile preference store of the +// profile identified by id. +func (pm *ProfileManager) ProfilePrefs(id string) (*profilemanager.Prefs, error) { + prefs, err := pm.serviceMgr.ProfilePrefs(profilemanager.ID(id), pm.username) + if err != nil { + return nil, fmt.Errorf("resolve profile prefs: %w", err) + } + return prefs, nil +} + +// GetConfigPath returns the config file path for the given profile ID. The +// platform code should call this instead of constructing paths itself. +func (pm *ProfileManager) GetConfigPath(id string) (string, error) { + return pm.getProfileConfigPath(id) +} + +// GetStateFilePath returns the state file path for the given profile ID. +func (pm *ProfileManager) GetStateFilePath(id string) (string, error) { + if id == "" || id == profilemanager.DefaultProfileName { + return filepath.Join(pm.configDir, "state.json"), nil + } + + if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) { + return "", fmt.Errorf("id %q is not valid", id) + } + + profilesDir := filepath.Join(pm.configDir, profilesSubdir) + return filepath.Join(profilesDir, id+".state.json"), nil +} + +// GetActiveConfigPath returns the config file path for the currently active +// profile. +func (pm *ProfileManager) GetActiveConfigPath() (string, error) { + activeProfile, err := pm.GetActiveProfile() + if err != nil { + return "", fmt.Errorf("get active profile: %w", err) + } + return pm.GetConfigPath(activeProfile.ID) +} + +// GetActiveStateFilePath returns the state file path for the currently active +// profile. +func (pm *ProfileManager) GetActiveStateFilePath() (string, error) { + activeProfile, err := pm.GetActiveProfile() + if err != nil { + return "", fmt.Errorf("get active profile: %w", err) + } + return pm.GetStateFilePath(activeProfile.ID) +} + +// SetMDMLoader registers the MDM policy source consulted before profile +// mutations; a nil loader disables enforcement. +func (pm *ProfileManager) SetMDMLoader(loader *mdm.Loader) { + pm.mdmLoader = loader +} + +func (pm *ProfileManager) checkProfilesAllowed() error { + if v, ok := pm.mdmLoader.Load().GetBool(mdm.KeyDisableProfiles); ok && v { + return ErrProfilesDisabled + } + return nil +} + +func (pm *ProfileManager) checkProfileLogoutAllowed(id string) error { + active, err := pm.serviceMgr.GetActiveProfileState() + if err == nil && active.ID.String() == id { + return nil + } + return pm.checkProfilesAllowed() +} + +// 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) +} + +// getProfileConfigPath returns the config file path for a profile ID. The +// default profile uses netbird.cfg in the root configDir; other profiles use +// .json in the profiles/ subdirectory. +func (pm *ProfileManager) getProfileConfigPath(id string) (string, error) { + if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) { + return "", fmt.Errorf("id %q is not valid", id) + } + + if id == profilemanager.DefaultProfileName { + return filepath.Join(pm.configDir, defaultConfigFilename), nil + } + + profilesDir := filepath.Join(pm.configDir, profilesSubdir) + return filepath.Join(profilesDir, id+".json"), nil +} diff --git a/client/mobile/profile_manager_mdm_test.go b/client/mobile/profile_manager_mdm_test.go new file mode 100644 index 000000000..305becac3 --- /dev/null +++ b/client/mobile/profile_manager_mdm_test.go @@ -0,0 +1,83 @@ +package mobile + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" +) + +type fakeFetcher struct{ values map[string]any } + +func (f *fakeFetcher) Fetch() map[string]any { return f.values } + +func newTestProfileManager(t *testing.T) *ProfileManager { + t.Helper() + origDir := profilemanager.DefaultConfigPathDir + origPath := profilemanager.DefaultConfigPath + origActive := profilemanager.ActiveProfileStatePath + t.Cleanup(func() { + profilemanager.DefaultConfigPathDir = origDir + profilemanager.DefaultConfigPath = origPath + profilemanager.ActiveProfileStatePath = origActive + }) + + configDir := t.TempDir() + pm := NewProfileManager(configDir, "mobile") + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: filepath.Join(configDir, defaultConfigFilename), + }) + require.NoError(t, err) + return pm +} + +func privateKeyOf(t *testing.T, pm *ProfileManager, id string) string { + t.Helper() + path, err := pm.getProfileConfigPath(id) + require.NoError(t, err) + raw, err := os.ReadFile(path) + require.NoError(t, err) + var cfg struct{ PrivateKey string } + require.NoError(t, json.Unmarshal(raw, &cfg)) + return cfg.PrivateKey +} + +func TestLogoutProfile_DisableProfiles(t *testing.T) { + pm := newTestProfileManager(t) + other, err := pm.AddProfile("work") + require.NoError(t, err) + require.NoError(t, pm.SwitchProfile(profilemanager.DefaultProfileName)) + require.NotEmpty(t, privateKeyOf(t, pm, profilemanager.DefaultProfileName)) + require.NotEmpty(t, privateKeyOf(t, pm, other.ID)) + + pm.SetMDMLoader(mdm.NewLoader(&fakeFetcher{values: map[string]any{ + mdm.KeyDisableProfiles: true, + }})) + + err = pm.LogoutProfile(other.ID) + assert.ErrorIs(t, err, ErrProfilesDisabled) + assert.NotEmpty(t, privateKeyOf(t, pm, other.ID)) + + require.NoError(t, pm.LogoutProfile(profilemanager.DefaultProfileName)) + assert.Empty(t, privateKeyOf(t, pm, profilemanager.DefaultProfileName)) +} + +func TestLogoutProfile_ProfilesAllowed(t *testing.T) { + pm := newTestProfileManager(t) + other, err := pm.AddProfile("work") + require.NoError(t, err) + require.NoError(t, pm.SwitchProfile(profilemanager.DefaultProfileName)) + + pm.SetMDMLoader(mdm.NewLoader(&fakeFetcher{values: map[string]any{ + mdm.KeyDisableProfiles: false, + }})) + + require.NoError(t, pm.LogoutProfile(other.ID)) + assert.Empty(t, privateKeyOf(t, pm, other.ID)) +} diff --git a/client/mobile/profile_state.go b/client/mobile/profile_state.go new file mode 100644 index 000000000..ad05801f8 --- /dev/null +++ b/client/mobile/profile_state.go @@ -0,0 +1,110 @@ +package mobile + +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 ( + // 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 mobile both resolve under configDir, 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, .json -> .account.json. +// +// Deriving from the config path rather than resolving the active profile keeps +// the write on the profile the login actually ran for: login flows run 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 +} + +// 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) + } + + // DirectWriteJson, not the atomic writers: those create a temp file and + // rename it over the target, which the tvOS App Group sandbox blocks. It is + // the same reason the config next to this file goes through + // DirectWriteOutConfig. The file is rewritten whole from one key, so losing + // atomicity costs nothing beyond a torn write on a crash mid-write, which + // reads back as "no email" and is recovered by the next login. + state := profilemanager.ProfileState{Email: email} + if err := util.DirectWriteJson(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 +} diff --git a/client/mobile/profile_state_test.go b/client/mobile/profile_state_test.go new file mode 100644 index 000000000..99cba15de --- /dev/null +++ b/client/mobile/profile_state_test.go @@ -0,0 +1,161 @@ +package mobile + +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/netbird/files/netbird.cfg", + want: filepath.FromSlash("/data/netbird/files/netbird.account.json"), + }, + { + name: "id profile", + configPath: "/data/netbird/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.json", + want: filepath.FromSlash("/data/netbird/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.account.json"), + }, + { + name: "legacy name-keyed profile is handled the same way", + configPath: "/data/netbird/files/profiles/work.json", + want: filepath.FromSlash("/data/netbird/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/netbird/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 mobile both +// resolve under configDir, 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/netbird/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) +} diff --git a/client/net/fwmark.go b/client/net/fwmark.go new file mode 100644 index 000000000..b526feee4 --- /dev/null +++ b/client/net/fwmark.go @@ -0,0 +1,110 @@ +package net + +import ( + "fmt" + "os" + "strconv" + "strings" + + log "github.com/sirupsen/logrus" +) + +const ( + // envFwmarkBase overrides the base of the fwmark range. Container network + // plugins, CNIs and other VPNs claim bits of the mark space for themselves, + // and a rule of theirs matching one of our bits acts on our traffic, so + // hosts running such software may need to move the range out of the way. + envFwmarkBase = "NB_FWMARK_BASE" + + // defaultFwmarkBase is the base of the fwmark range used when the + // environment does not override it. + defaultFwmarkBase uint32 = 0x1BD00 + + // fwmarkOffsetMask is the part of a mark that identifies the individual mark + // within the range, so the base occupies everything above it. + fwmarkOffsetMask uint32 = 0xFF +) + +// Offsets of the individual marks within the range. +const ( + offsetControlPlane uint32 = 0x00 + offsetDataPlaneIn uint32 = 0x10 + offsetDataPlaneOut uint32 = 0x11 + offsetRedirected uint32 = 0x20 + offsetMasquerade uint32 = 0x21 + offsetMasqueradeReturn uint32 = 0x22 + offsetDataPlaneLower uint32 = 0x10 + offsetDataPlaneUpper uint32 = fwmarkOffsetMask +) + +var ( + fwmarkBase = loadFwmarkBase() + + // ControlPlaneMark is the fwmark value used to mark packets that should not be routed through the NetBird interface to + // avoid routing loops. + // This includes all control plane traffic (mgmt, signal, flows), relay, ICE/stun/turn and everything that is emitted by the wireguard socket. + // It doesn't collide with the other marks, as the others are used for data plane traffic only. + ControlPlaneMark = fwmarkBase | offsetControlPlane + + // DataPlaneMarkLower is the lowest value for the data plane range + DataPlaneMarkLower = fwmarkBase | offsetDataPlaneLower + // DataPlaneMarkUpper is the highest value for the data plane range + DataPlaneMarkUpper = fwmarkBase | offsetDataPlaneUpper + + // DataPlaneMarkIn is the mark for inbound data plane traffic. + DataPlaneMarkIn = fwmarkBase | offsetDataPlaneIn + + // DataPlaneMarkOut is the mark for outbound data plane traffic. + DataPlaneMarkOut = fwmarkBase | offsetDataPlaneOut + + // PreroutingFwmarkRedirected is applied to packets that were redirected (input -> forward, e.g. by Docker or Podman) for special handling. + PreroutingFwmarkRedirected = fwmarkBase | offsetRedirected + + // PreroutingFwmarkMasquerade is applied to packets that arrive from the NetBird interface and should be masqueraded. + PreroutingFwmarkMasquerade = fwmarkBase | offsetMasquerade + + // PreroutingFwmarkMasqueradeReturn is applied to packets that will leave through the NetBird interface and should be masqueraded. + PreroutingFwmarkMasqueradeReturn = fwmarkBase | offsetMasqueradeReturn +) + +// IsDataPlaneMark determines if a fwmark is in the data plane range. +func IsDataPlaneMark(fwmark uint32) bool { + return fwmark >= DataPlaneMarkLower && fwmark <= DataPlaneMarkUpper +} + +func loadFwmarkBase() uint32 { + val := os.Getenv(envFwmarkBase) + if val == "" { + return defaultFwmarkBase + } + + base, err := parseFwmarkBase(val) + if err != nil { + log.Warnf("failed to parse %s=%q, using the default range: %v", envFwmarkBase, val, err) + return defaultFwmarkBase + } + + log.Infof("using fwmark range %#x-%#x from %s", base, base|fwmarkOffsetMask, envFwmarkBase) + return base +} + +// parseFwmarkBase reads a mark range base. The low byte of a mark identifies the +// individual mark within the range, so a base has to leave it free. +func parseFwmarkBase(val string) (uint32, error) { + val = strings.TrimSpace(val) + + base, err := strconv.ParseUint(val, 0, 32) + if err != nil { + return 0, fmt.Errorf("not a 32 bit number: %w", err) + } + + if base == 0 { + return 0, fmt.Errorf("base must not be zero") + } + + if uint32(base)&fwmarkOffsetMask != 0 { + return 0, fmt.Errorf("base %#x must leave the low byte free", base) + } + + return uint32(base), nil +} diff --git a/client/net/fwmark_test.go b/client/net/fwmark_test.go new file mode 100644 index 000000000..2dbebec2a --- /dev/null +++ b/client/net/fwmark_test.go @@ -0,0 +1,111 @@ +package net + +import ( + "testing" +) + +func TestParseFwmarkBase(t *testing.T) { + tests := []struct { + name string + val string + want uint32 + wantErr bool + }{ + {name: "hex", val: "0x5A000", want: 0x5A000}, + {name: "hex upper case", val: "0X5A000", want: 0x5A000}, + {name: "decimal", val: "65536", want: 65536}, + {name: "octal", val: "0o400", want: 0o400}, + {name: "surrounding space", val: " 0x5A000 ", want: 0x5A000}, + {name: "highest usable base", val: "0xFFFFFF00", want: 0xFFFFFF00}, + {name: "low byte in use", val: "0x1BD01", wantErr: true}, + {name: "zero", val: "0", wantErr: true}, + {name: "not a number", val: "wireguard", wantErr: true}, + {name: "wider than 32 bit", val: "0x1FFFFFFFF", wantErr: true}, + {name: "negative", val: "-0x100", wantErr: true}, + {name: "empty", val: "", wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := parseFwmarkBase(tc.val) + if tc.wantErr { + if err == nil { + t.Fatalf("parseFwmarkBase(%q) = %#x, want an error", tc.val, got) + } + return + } + if err != nil { + t.Fatalf("parseFwmarkBase(%q): %v", tc.val, err) + } + if got != tc.want { + t.Errorf("parseFwmarkBase(%q) = %#x, want %#x", tc.val, got, tc.want) + } + }) + } +} + +// The marks have to stay inside the range the base defines, otherwise a host +// that moved the range to dodge a collision would still emit the old values. +func TestMarksStayWithinTheRange(t *testing.T) { + lower, upper := fwmarkBase, fwmarkBase|fwmarkOffsetMask + + marks := map[string]uint32{ + "ControlPlaneMark": ControlPlaneMark, + "DataPlaneMarkLower": DataPlaneMarkLower, + "DataPlaneMarkUpper": DataPlaneMarkUpper, + "DataPlaneMarkIn": DataPlaneMarkIn, + "DataPlaneMarkOut": DataPlaneMarkOut, + "PreroutingFwmarkRedirected": PreroutingFwmarkRedirected, + "PreroutingFwmarkMasquerade": PreroutingFwmarkMasquerade, + "PreroutingFwmarkMasqueradeReturn": PreroutingFwmarkMasqueradeReturn, + } + + for name, mark := range marks { + if mark < lower || mark > upper { + t.Errorf("%s = %#x, outside the range %#x-%#x", name, mark, lower, upper) + } + } + + // the control plane mark must stay out of the data plane range, the netflow + // conntrack path tells them apart by it + if IsDataPlaneMark(ControlPlaneMark) { + t.Errorf("ControlPlaneMark %#x is inside the data plane range", ControlPlaneMark) + } + for name, mark := range map[string]uint32{ + "DataPlaneMarkIn": DataPlaneMarkIn, + "DataPlaneMarkOut": DataPlaneMarkOut, + "PreroutingFwmarkRedirected": PreroutingFwmarkRedirected, + "PreroutingFwmarkMasquerade": PreroutingFwmarkMasquerade, + "PreroutingFwmarkMasqueradeReturn": PreroutingFwmarkMasqueradeReturn, + } { + if !IsDataPlaneMark(mark) { + t.Errorf("%s = %#x is outside the data plane range %#x-%#x", name, mark, DataPlaneMarkLower, DataPlaneMarkUpper) + } + } +} + +func TestDefaultMarksAreUnchanged(t *testing.T) { + tests := map[string]struct { + got uint32 + want uint32 + }{ + "ControlPlaneMark": {ControlPlaneMark, 0x1BD00}, + "DataPlaneMarkLower": {DataPlaneMarkLower, 0x1BD10}, + "DataPlaneMarkUpper": {DataPlaneMarkUpper, 0x1BDFF}, + "DataPlaneMarkIn": {DataPlaneMarkIn, 0x1BD10}, + "DataPlaneMarkOut": {DataPlaneMarkOut, 0x1BD11}, + "PreroutingFwmarkRedirected": {PreroutingFwmarkRedirected, 0x1BD20}, + "PreroutingFwmarkMasquerade": {PreroutingFwmarkMasquerade, 0x1BD21}, + "PreroutingFwmarkMasqueradeReturn": {PreroutingFwmarkMasqueradeReturn, 0x1BD22}, + } + + if fwmarkBase != defaultFwmarkBase { + t.Skipf("%s is set, the defaults do not apply", envFwmarkBase) + } + + for name, tc := range tests { + if tc.got != tc.want { + t.Errorf("%s = %#x, want %#x", name, tc.got, tc.want) + } + } +} diff --git a/client/net/net.go b/client/net/net.go index a97de9d59..77fba36d1 100644 --- a/client/net/net.go +++ b/client/net/net.go @@ -7,41 +7,6 @@ import ( "net/netip" ) -const ( - // ControlPlaneMark is the fwmark value used to mark packets that should not be routed through the NetBird interface to - // avoid routing loops. - // This includes all control plane traffic (mgmt, signal, flows), relay, ICE/stun/turn and everything that is emitted by the wireguard socket. - // It doesn't collide with the other marks, as the others are used for data plane traffic only. - ControlPlaneMark = 0x1BD00 - - // Data plane marks (0x1BD10 - 0x1BDFF) - - // DataPlaneMarkLower is the lowest value for the data plane range - DataPlaneMarkLower = 0x1BD10 - // DataPlaneMarkUpper is the highest value for the data plane range - DataPlaneMarkUpper = 0x1BDFF - - // DataPlaneMarkIn is the mark for inbound data plane traffic. - DataPlaneMarkIn = 0x1BD10 - - // DataPlaneMarkOut is the mark for outbound data plane traffic. - DataPlaneMarkOut = 0x1BD11 - - // PreroutingFwmarkRedirected is applied to packets that are were redirected (input -> forward, e.g. by Docker or Podman) for special handling. - PreroutingFwmarkRedirected = 0x1BD20 - - // PreroutingFwmarkMasquerade is applied to packets that arrive from the NetBird interface and should be masqueraded. - PreroutingFwmarkMasquerade = 0x1BD21 - - // PreroutingFwmarkMasqueradeReturn is applied to packets that will leave through the NetBird interface and should be masqueraded. - PreroutingFwmarkMasqueradeReturn = 0x1BD22 -) - -// IsDataPlaneMark determines if a fwmark is in the data plane range (0x1BD10-0x1BDFF) -func IsDataPlaneMark(fwmark uint32) bool { - return fwmark >= DataPlaneMarkLower && fwmark <= DataPlaneMarkUpper -} - func GetLastIPFromNetwork(network netip.Prefix, fromEnd int) (netip.Addr, error) { var endIP net.IP addr := network.Addr().AsSlice() diff --git a/client/net/net_linux.go b/client/net/net_linux.go index 9e7d13702..8ed8a1944 100644 --- a/client/net/net_linux.go +++ b/client/net/net_linux.go @@ -21,15 +21,6 @@ func SetSocketMark(conn syscall.Conn) error { return setRawSocketMark(sysconn) } -// SetSocketOpt sets the SO_MARK option on the given file descriptor -func SetSocketOpt(fd int) error { - if !AdvancedRouting() { - return nil - } - - return setSocketOptInt(fd) -} - func setRawSocketMark(conn syscall.RawConn) error { var setErr error @@ -51,5 +42,5 @@ func setRawSocketMark(conn syscall.RawConn) error { } func setSocketOptInt(fd int) error { - return syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_MARK, ControlPlaneMark) + return syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_MARK, int(ControlPlaneMark)) } diff --git a/client/netevents/netevents.go b/client/netevents/netevents.go new file mode 100644 index 000000000..474cbfa22 --- /dev/null +++ b/client/netevents/netevents.go @@ -0,0 +1,173 @@ +// Package netevents owns the OS network event handling shared by the mobile +// bindings: availability changes park or wake the reconnection loops and drive +// the NoNetwork listener state, and both losing the last network and switching +// networks sweep the stale connections so their owners redial immediately. +package netevents + +import ( + "context" + "sync" + "time" + + "github.com/cenkalti/backoff/v4" + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/netevents/netstate" + "github.com/netbirdio/netbird/client/netevents/sweep" +) + +// Recorder receives the availability changes for listener state reporting. +type Recorder interface { + SetNetworkAvailable(available bool) +} + +// Manager ties the network availability state, the connection sweeper and the +// status recorder together; it outlives engine restarts. A nil *Manager is +// the valid no-events value for consumers: the read methods report +// always-online and never sweep. Only the event sources hold a real Manager, +// so the write methods do not tolerate a nil receiver. +type Manager struct { + // mu serializes availability transitions: the IsOnline check and the + // state update must be atomic, or a racing offline flip can skip the sweep + // and leave netState and the recorder disagreeing. + mu sync.Mutex + netState *netstate.State + sweeper *sweep.Sweeper + recorder Recorder +} + +// NewManager creates a Manager reporting into recorder, starting online. +func NewManager(recorder Recorder) *Manager { + return &Manager{ + netState: netstate.New(), + sweeper: sweep.New(), + recorder: recorder, + } +} + +// SetNetworkAvailable records OS-reported network availability. While +// unavailable, the reconnection loops suspend their attempts and the +// connection listener reports NoNetwork instead of Connecting; when +// availability returns, the loops resume immediately with a fresh backoff. +// Losing the last network also sweeps the registered connections: nothing can +// redial while offline, so the stale sockets would otherwise stay silently +// "connected" until their own timeouts and the client would keep reporting +// Connected with no network at all. +// +// Panics on a nil receiver: only the mobile bindings that own a Manager +// report availability. +func (m *Manager) SetNetworkAvailable(available bool) { + m.mu.Lock() + defer m.mu.Unlock() + + if !available && m.netState.IsOnline() { + m.sweeper.MarkNetworkChange() + } + m.netState.Set(available) + m.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. +// +// Panics on a nil receiver: only the mobile bindings that own a Manager +// report network changes. +func (m *Manager) NotifyNetworkChange() { + m.sweeper.MarkNetworkChange() + log.Infof("network change: connections marked stale") +} + +// IsOnline reports whether the OS reports at least one usable network. +func (m *Manager) IsOnline() bool { + if m == nil { + return true + } + return m.netState.IsOnline() +} + +// Changed returns a channel closed on the next availability transition. +func (m *Manager) Changed() <-chan struct{} { + if m == nil { + return nil + } + return m.netState.Changed() +} + +// Wait blocks while the network is offline; see netstate.State.Wait. +func (m *Manager) Wait(ctx context.Context) (bool, error) { + if m == nil { + return false, nil + } + return m.netState.Wait(ctx) +} + +// WaitSettled waits until an online verdict holds for a full settleWindow, or +// while offline until the budget runs out. Returns false when ctx is +// cancelled. The settle window exists because a disconnect often precedes the +// OS offline flag by a few milliseconds, so a fresh online verdict cannot be +// trusted immediately. A nil Manager has no events to watch: it degrades to a +// fixed budget-long sleep. +func (m *Manager) WaitSettled(ctx context.Context, budget, settleWindow time.Duration) bool { + if m == nil { + select { + case <-time.After(budget): + return true + case <-ctx.Done(): + return false + } + } + + budgetTimer := time.NewTimer(budget) + defer budgetTimer.Stop() + + settle := time.NewTimer(settleWindow) + defer settle.Stop() + + for { + // Channel first, flag second: a flip in between still fires the channel. + changedCh := m.netState.Changed() + if m.netState.IsOnline() { + select { + case <-settle.C: + return true + case <-changedCh: + case <-ctx.Done(): + return false + } + } else { + select { + case <-budgetTimer.C: + return true + case <-changedCh: + case <-ctx.Done(): + return false + } + } + if !settle.Stop() { + select { + case <-settle.C: + default: + } + } + settle.Reset(settleWindow) + } +} + +// StartDial registers an in-flight dial with the sweeper; see sweep.Sweeper.StartDial. +func (m *Manager) StartDial(ctx context.Context) *sweep.Dial { + if m == nil { + return (*sweep.Sweeper)(nil).StartDial(ctx) + } + return m.sweeper.StartDial(ctx) +} + +// QuickRetryBackoff wraps bo for a quick retry after a network change; see +// sweep.Sweeper.QuickRetryBackoff. +func (m *Manager) QuickRetryBackoff(ctx context.Context, bo backoff.BackOff) backoff.BackOff { + if m == nil { + return bo + } + return m.sweeper.QuickRetryBackoff(ctx, bo, m.netState) +} diff --git a/client/netevents/netevents_test.go b/client/netevents/netevents_test.go new file mode 100644 index 000000000..a62ddc270 --- /dev/null +++ b/client/netevents/netevents_test.go @@ -0,0 +1,34 @@ +package netevents + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +type recorderStub struct{} + +func (recorderStub) SetNetworkAvailable(bool) {} + +func TestWaitSettledAfterOutage(t *testing.T) { + const budget = 1500 * time.Millisecond + const settleWindow = 200 * time.Millisecond + const outage = 2 * settleWindow + + m := NewManager(recorderStub{}) + m.SetNetworkAvailable(false) + + start := time.Now() + go func() { + time.Sleep(outage) + m.SetNetworkAvailable(true) + }() + + ok := m.WaitSettled(context.Background(), budget, settleWindow) + elapsed := time.Since(start) + + assert.True(t, ok, "recovered network must let the caller proceed") + assert.GreaterOrEqual(t, elapsed, outage+settleWindow, "an online verdict must hold a full settle window before it is trusted") +} diff --git a/client/netevents/netstate/netstate.go b/client/netevents/netstate/netstate.go new file mode 100644 index 000000000..0d7a1268b --- /dev/null +++ b/client/netevents/netstate/netstate.go @@ -0,0 +1,110 @@ +// Package netstate tracks OS-reported network availability for the client. +// +// A State instance is owned by the platform integration (e.g. the Android or +// iOS bindings, fed from ConnectivityManager callbacks or NWPathMonitor) and +// is injected into the connection retry loops (management, signal, relay, +// peer guards and the top-level connect loop), which consult it to avoid +// burning CPU and battery on reconnect attempts while the device has no +// network at all (e.g. airplane mode), and to reset their backoff as soon as +// the network returns. +// +// Consumers hold a *State that may be nil — every non-mobile platform leaves +// it unset. The read methods are safe on a nil receiver: they report online +// and never block, so consumers behave as if this package did not exist. +package netstate + +import ( + "context" + "sync" + + log "github.com/sirupsen/logrus" +) + +// State holds the OS-reported network availability. The zero value is not +// usable; create instances with New. +type State struct { + mu sync.Mutex + online bool + changed chan struct{} +} + +// New creates a State that starts online. Platforms without network tracking +// pass a nil *State instead: the read methods treat nil as always online and +// never block, so consumers need no nil guards. +func New() *State { + return &State{ + online: true, + changed: make(chan struct{}), + } +} + +// Set records whether the OS reports any usable network. Transitions wake up +// all Wait callers immediately. Unlike the read methods, Set is not nil-safe: +// it is only for the platform owner that created the State with New. +func (s *State) Set(online bool) { + s.mu.Lock() + defer s.mu.Unlock() + if s.online == online { + return + } + s.online = online + close(s.changed) + s.changed = make(chan struct{}) + log.Infof("OS network availability changed: online=%t", online) +} + +// IsOnline reports whether the OS reports at least one usable network. On a +// nil receiver — no State injected — it reports online. +func (s *State) IsOnline() bool { + if s == nil { + return true + } + s.mu.Lock() + defer s.mu.Unlock() + return s.online +} + +// Changed returns a channel closed on the next availability transition, for +// callers that already own a select loop and cannot block in Wait. Re-read it +// after every fire: each transition installs a fresh channel. On a nil +// receiver — no State injected — it returns nil, which blocks forever in a +// select, so the caller simply never observes a transition. +func (s *State) Changed() <-chan struct{} { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + return s.changed +} + +// Wait blocks while the network is offline. It reports whether it had to +// wait, so callers can reset their backoff after an outage. It returns early +// with the context error when ctx is done. On a nil receiver — no State +// injected — it returns immediately. +func (s *State) Wait(ctx context.Context) (bool, error) { + if s == nil { + return false, nil + } + waited := false + for { + s.mu.Lock() + if s.online { + s.mu.Unlock() + return waited, nil + } + ch := s.changed + s.mu.Unlock() + + if !waited { + waited = true + log.Debugf("network is offline, pausing connection attempts") + } + + select { + case <-ctx.Done(): + return waited, ctx.Err() + case <-ch: + } + } +} diff --git a/client/netevents/netstate/netstate_test.go b/client/netevents/netstate/netstate_test.go new file mode 100644 index 000000000..ea7015761 --- /dev/null +++ b/client/netevents/netstate/netstate_test.go @@ -0,0 +1,170 @@ +package netstate + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewStateIsOnline(t *testing.T) { + assert.True(t, New().IsOnline(), "a fresh State should start online") +} + +func TestSetTogglesOnlineState(t *testing.T) { + s := New() + + s.Set(false) + assert.False(t, s.IsOnline(), "state should be offline after Set(false)") + + s.Set(true) + assert.True(t, s.IsOnline(), "state should be online after Set(true)") +} + +func TestWaitReturnsImmediatelyWhenOnline(t *testing.T) { + s := New() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + waited, err := s.Wait(ctx) + require.NoError(t, err) + assert.False(t, waited, "Wait should not block when the network is online") +} + +func TestWaitBlocksUntilOnline(t *testing.T) { + s := New() + s.Set(false) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + result := make(chan bool, 1) + go func() { + waited, err := s.Wait(ctx) + if err != nil { + result <- false + return + } + result <- waited + }() + + // Verify Wait is actually blocking while offline + select { + case <-result: + t.Fatal("Wait should block while the network is offline") + case <-time.After(100 * time.Millisecond): + } + + s.Set(true) + + select { + case waited := <-result: + assert.True(t, waited, "Wait should report that it had to wait for the network") + case <-time.After(2 * time.Second): + t.Fatal("Wait should return promptly after the network becomes available") + } +} + +func TestWaitReturnsOnContextCancel(t *testing.T) { + s := New() + s.Set(false) + + ctx, cancel := context.WithCancel(context.Background()) + + result := make(chan error, 1) + go func() { + _, err := s.Wait(ctx) + result <- err + }() + + cancel() + + select { + case err := <-result: + assert.ErrorIs(t, err, context.Canceled) + case <-time.After(2 * time.Second): + t.Fatal("Wait should return promptly after context cancellation") + } +} + +func TestWaitWakesAllWaiters(t *testing.T) { + s := New() + s.Set(false) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + const waiters = 10 + var wg sync.WaitGroup + results := make(chan bool, waiters) + for i := 0; i < waiters; i++ { + wg.Add(1) + go func() { + defer wg.Done() + waited, err := s.Wait(ctx) + if err != nil { + results <- false + return + } + results <- waited + }() + } + + time.Sleep(100 * time.Millisecond) + s.Set(true) + wg.Wait() + + close(results) + count := 0 + for waited := range results { + assert.True(t, waited, "every waiter should report that it waited") + count++ + } + assert.Equal(t, waiters, count, "all waiters should have returned") +} + +func TestNilStateReadsAreNoops(t *testing.T) { + var s *State + + assert.True(t, s.IsOnline(), "nil State should report online") + + waited, err := s.Wait(context.Background()) + require.NoError(t, err) + assert.False(t, waited, "nil State's Wait should not block") +} + +func TestConcurrentSetAndWait(t *testing.T) { + s := New() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var wg sync.WaitGroup + for i := 0; i < 4; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 100; j++ { + s.Set(j%2 == 0) + s.IsOnline() + } + }() + } + for i := 0; i < 4; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 100; j++ { + if _, err := s.Wait(ctx); err != nil { + return + } + } + }() + } + + wg.Wait() +} diff --git a/client/netevents/sweep/quick_retry.go b/client/netevents/sweep/quick_retry.go new file mode 100644 index 000000000..1e174b20a --- /dev/null +++ b/client/netevents/sweep/quick_retry.go @@ -0,0 +1,39 @@ +package sweep + +import ( + "time" + + "github.com/cenkalti/backoff/v4" + + "github.com/netbirdio/netbird/client/netevents/netstate" +) + +const quickRetryDelay = 200 * time.Millisecond + +type quickRetryBackoff struct { + backoff.BackOff + sweeper *Sweeper + netState *netstate.State + used bool +} + +func newQuickRetryBackoff(bo backoff.BackOff, sweeper *Sweeper, netState *netstate.State) *quickRetryBackoff { + return &quickRetryBackoff{ + BackOff: bo, + sweeper: sweeper, + netState: netState, + } +} + +func (b *quickRetryBackoff) NextBackOff() time.Duration { + if !b.used && b.sweeper.markedRecently() && b.netState.IsOnline() { + b.used = true + return quickRetryDelay + } + return b.BackOff.NextBackOff() +} + +func (b *quickRetryBackoff) Reset() { + b.used = false + b.BackOff.Reset() +} diff --git a/client/netevents/sweep/quick_retry_test.go b/client/netevents/sweep/quick_retry_test.go new file mode 100644 index 000000000..3dadd951c --- /dev/null +++ b/client/netevents/sweep/quick_retry_test.go @@ -0,0 +1,58 @@ +package sweep + +import ( + "context" + "testing" + "time" + + "github.com/cenkalti/backoff/v4" + "github.com/stretchr/testify/assert" +) + +func TestQuickRetryAfterRecentMark(t *testing.T) { + sweeper := New() + sweeper.MarkNetworkChange() + + slow := backoff.NewConstantBackOff(5 * time.Second) + bo := sweeper.QuickRetryBackoff(context.Background(), slow, nil) + + assert.Equal(t, quickRetryDelay, bo.NextBackOff(), "first retry after a mark must be quick") + assert.Equal(t, 5*time.Second, bo.NextBackOff(), "second retry must fall back to the wrapped backoff") + + bo.Reset() + assert.Equal(t, quickRetryDelay, bo.NextBackOff(), "reset must re-arm the quick retry") +} + +func TestQuickRetryWithoutMarkKeepsSpread(t *testing.T) { + sweeper := New() + + slow := backoff.NewConstantBackOff(5 * time.Second) + bo := sweeper.QuickRetryBackoff(context.Background(), slow, nil) + + assert.Equal(t, 5*time.Second, bo.NextBackOff(), "without a mark the wrapped backoff decides") + + sweeper.mu.Lock() + sweeper.lastMark = time.Now().Add(-recentMarkWindow) + sweeper.mu.Unlock() + assert.Equal(t, 5*time.Second, bo.NextBackOff(), "a stale mark must not trigger the quick retry") +} + +func TestQuickRetryNilSweeperPassthrough(t *testing.T) { + var sweeper *Sweeper + + slow := backoff.NewConstantBackOff(5 * time.Second) + bo := sweeper.QuickRetryBackoff(context.Background(), slow, nil) + + assert.Equal(t, backoff.BackOff(slow), bo, "nil sweeper must return the backoff unchanged") +} + +func TestQuickRetryHonorsContext(t *testing.T) { + sweeper := New() + sweeper.MarkNetworkChange() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + bo := sweeper.QuickRetryBackoff(ctx, backoff.NewConstantBackOff(time.Millisecond), nil) + + assert.Equal(t, backoff.Stop, bo.NextBackOff(), "cancelled context must stop the retry loop") +} diff --git a/client/netevents/sweep/sweep.go b/client/netevents/sweep/sweep.go new file mode 100644 index 000000000..52dce92be --- /dev/null +++ b/client/netevents/sweep/sweep.go @@ -0,0 +1,267 @@ +// Package sweep cuts network-bound activity when the OS switches networks: +// a sweep closes the registered connections and aborts the in-flight dials, so +// their owners redial immediately instead of waiting for the old sockets to +// time out. +// +// A nil *Sweeper disables everything: all methods are nil-safe no-ops. +package sweep + +import ( + "context" + "errors" + "net" + "sync" + "time" + + "github.com/cenkalti/backoff/v4" + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/netevents/netstate" +) + +// DefaultSweepDelay absorbs network flapping while the OS settles on a +// default network before the stale registrations are cut. +const DefaultSweepDelay = 500 * time.Millisecond + +const recentMarkWindow = 3 * time.Second + +// Config customizes a Sweeper. The zero value applies the defaults. +type Config struct { + // SweepDelay overrides DefaultSweepDelay when positive. + SweepDelay time.Duration +} + +// ErrSwept reports that a dial finished after a network change swept its +// registration. The connection is already closed; the caller must treat it +// as a failed dial and redial on the new network. +var ErrSwept = errors.New("sweep: connection swept by network change") + +// sweepID identifies one registration in a sweeper. Connections and dials +// draw from the same counter, so an id is unique across both registries. +type sweepID uint64 + +type connEntry struct { + conn net.Conn + gen uint64 +} + +// Dial tracks one dial from start to connection registration. It hands the +// dialed connection to the sweeper atomically, so a sweep can never fall +// between the dial finishing and the connection being registered. +type Dial struct { + sweeper *Sweeper + ctx context.Context + cancel context.CancelFunc + id sweepID + done bool // set by a sweep, WrapConn or Release; guarded by sweeper.mu + gen uint64 +} + +// Ctx returns the dial's context. A sweep cancels it, so a dial started on the +// old network aborts instead of waiting out its handshake timeout. +func (d *Dial) Ctx() context.Context { + return d.ctx +} + +// Release ends the dial's registration and cancels its context. It is +// idempotent and safe after WrapConn, so callers can defer it. +func (d *Dial) Release() { + s := d.sweeper + if s == nil { + return + } + + s.mu.Lock() + d.done = true + delete(s.dials, d.id) + s.mu.Unlock() + + d.cancel() +} + +// sweptConn deregisters itself from the sweeper when closed. +type sweptConn struct { + net.Conn + sweeper *Sweeper + id sweepID +} + +func (c *sweptConn) Close() error { + c.sweeper.deregister(c.id) + return c.Conn.Close() +} + +// Sweeper registers live connections and in-flight dials so the +// network-change sweep can cut everything registered before the change. +type Sweeper struct { + mu sync.Mutex + conns map[sweepID]connEntry + dials map[sweepID]*Dial + nextID sweepID + gen uint64 + timer *time.Timer + sweepDelay time.Duration + lastMark time.Time +} + +// New creates an empty sweeper with the default configuration. +func New() *Sweeper { + return NewWithConfig(Config{}) +} + +// NewWithConfig creates an empty sweeper customized by cfg. +func NewWithConfig(cfg Config) *Sweeper { + delay := cfg.SweepDelay + if delay <= 0 { + delay = DefaultSweepDelay + } + return &Sweeper{ + conns: make(map[sweepID]connEntry), + dials: make(map[sweepID]*Dial), + sweepDelay: delay, + } +} + +// StartDial registers an in-flight dial. Dial with Ctx, hand the result to +// WrapConn, and Release the dial when the attempt is over, typically deferred. +func (s *Sweeper) StartDial(ctx context.Context) *Dial { + if s == nil { + return &Dial{ctx: ctx} + } + + ctx, cancel := context.WithCancel(ctx) + d := &Dial{sweeper: s, ctx: ctx, cancel: cancel} + + s.mu.Lock() + d.id = s.nextID + s.nextID++ + d.gen = s.gen + s.dials[d.id] = d + s.mu.Unlock() + + return d +} + +// WrapConn hands conn over to the sweeper. If a sweep ran since StartDial, +// the connection belongs to the old network: it is closed and ErrSwept is +// returned. Otherwise conn is registered against the next sweep and returned +// wrapped, deregistering itself on Close. Call it once, before Release. +func (d *Dial) WrapConn(conn net.Conn) (net.Conn, error) { + s := d.sweeper + if s == nil { + return conn, nil + } + + s.mu.Lock() + if d.done { + s.mu.Unlock() + if err := conn.Close(); err != nil { + log.Debugf("swept dial close error: %v", err) + } + return nil, ErrSwept + } + d.done = true + delete(s.dials, d.id) + id := s.nextID + s.nextID++ + // The conn inherits the dial's generation: the socket was bound to the + // network that was default when the dial started, not when it finished. + s.conns[id] = connEntry{conn: conn, gen: d.gen} + s.mu.Unlock() + + return &sweptConn{Conn: conn, sweeper: s, id: id}, nil +} + +// MarkNetworkChange records that the OS switched networks: everything +// registered so far becomes stale, and a sweep is (re)scheduled after the +// configured delay to cut whatever is still stale by then. Owners that +// redialed in the meantime hold fresh-generation registrations and survive, +// so no cancellation is needed around the sweep. +func (s *Sweeper) MarkNetworkChange() { + if s == nil { + return + } + + s.mu.Lock() + s.gen++ + cutoff := s.gen + s.lastMark = time.Now() + if s.timer != nil { + s.timer.Stop() + } + s.timer = time.AfterFunc(s.sweepDelay, func() { + n := s.sweep(cutoff) + log.Infof("network change sweep: closed %d stale connections", n) + }) + s.mu.Unlock() +} + +// QuickRetryBackoff wraps bo so that after each Reset the first retry comes +// quickly when the disconnect followed a recent network change and the +// network is online. Any other failure keeps bo's spread, so the clients of +// a restarted server still scatter their reconnects. A nil sweeper returns +// bo unchanged. +func (s *Sweeper) QuickRetryBackoff(ctx context.Context, bo backoff.BackOff, netState *netstate.State) backoff.BackOff { + if s == nil { + return bo + } + return backoff.WithContext(newQuickRetryBackoff(bo, s, netState), ctx) +} + +func (s *Sweeper) markedRecently() bool { + if s == nil { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + return !s.lastMark.IsZero() && time.Since(s.lastMark) < recentMarkWindow +} + +// sweep closes the registered connections and aborts the in-flight dials +// older than cutoff, and returns how many connections it closed. A dial +// whose connection was not yet handed to WrapConn is marked, so the late +// WrapConn closes it instead of registering it. +func (s *Sweeper) sweep(cutoff uint64) int { + if s == nil { + return 0 + } + + s.mu.Lock() + var conns []net.Conn + for id, e := range s.conns { + if e.gen < cutoff { + delete(s.conns, id) + conns = append(conns, e.conn) + } + } + var dials []*Dial + for id, d := range s.dials { + if d.gen < cutoff { + d.done = true + delete(s.dials, id) + dials = append(dials, d) + } + } + s.mu.Unlock() + + if len(dials) > 0 { + log.Debugf("aborting %d in-flight dials", len(dials)) + for _, d := range dials { + d.cancel() + } + } + + for _, conn := range conns { + log.Debugf("sweeping connection %s -> %s", conn.LocalAddr(), conn.RemoteAddr()) + if err := conn.Close(); err != nil { + log.Debugf("swept connection close error: %v", err) + } + } + return len(conns) +} + +func (s *Sweeper) deregister(id sweepID) { + s.mu.Lock() + delete(s.conns, id) + s.mu.Unlock() +} diff --git a/client/netevents/sweep/sweep_test.go b/client/netevents/sweep/sweep_test.go new file mode 100644 index 000000000..c162d4c0f --- /dev/null +++ b/client/netevents/sweep/sweep_test.go @@ -0,0 +1,241 @@ +package sweep + +import ( + "context" + "math" + "net" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSweepClosesRegisteredConns(t *testing.T) { + sweeper := New() + + c1 := wrap(t, sweeper, connPair(t)) + c2 := wrap(t, sweeper, connPair(t)) + + assert.Equal(t, 2, sweeper.sweepAll(), "both live connections should be closed") + + // The wrappers must report closed now. + buf := make([]byte, 1) + _, err := c1.Read(buf) + assert.Error(t, err, "first connection should be unusable after the sweep") + _, err = c2.Read(buf) + assert.Error(t, err, "second connection should be unusable after the sweep") + + assert.Equal(t, 0, sweeper.sweepAll(), "second sweep should find nothing") +} + +func TestCloseDeregisters(t *testing.T) { + sweeper := New() + + conn := wrap(t, sweeper, connPair(t)) + require.NoError(t, conn.Close()) + + assert.Equal(t, 0, sweeper.sweepAll(), "closed connection must leave the registry") +} + +func TestCloseIsIdempotent(t *testing.T) { + sweeper := New() + + conn := wrap(t, sweeper, connPair(t)) + require.NoError(t, conn.Close()) + assert.Error(t, conn.Close(), "double close surfaces the underlying error but must not panic") +} + +func TestSweepOnlyAffectsOlderConns(t *testing.T) { + sweeper := New() + + _ = wrap(t, sweeper, connPair(t)) + assert.Equal(t, 1, sweeper.sweepAll()) + + // A connection dialed after the sweep must survive until the next one. + _ = wrap(t, sweeper, connPair(t)) + assert.Equal(t, 1, sweeper.sweepAll(), "post-sweep connection belongs to the next sweep") +} + +func TestSweepAbortsInFlightDials(t *testing.T) { + sweeper := New() + + dial := sweeper.StartDial(context.Background()) + defer dial.Release() + + sweeper.sweepAll() + + assert.ErrorIs(t, dial.Ctx().Err(), context.Canceled, "sweep must cancel the in-flight dial context") +} + +func TestReleasedDialIsNotAborted(t *testing.T) { + sweeper := New() + + // Simulate a dial that finished before the sweep. + released := sweeper.StartDial(context.Background()) + released.Release() + + // A dial still in flight during the sweep. + pending := sweeper.StartDial(context.Background()) + defer pending.Release() + + sweeper.sweepAll() + assert.ErrorIs(t, pending.Ctx().Err(), context.Canceled, "pending dial must be aborted") +} + +func TestSweepBetweenDialAndHandoffClosesConn(t *testing.T) { + sweeper := New() + + dial := sweeper.StartDial(context.Background()) + defer dial.Release() + + // The dial succeeds on the old network, then the sweep lands before the + // connection is handed over. + conn := connPair(t) + assert.Equal(t, 0, sweeper.sweepAll(), "the connection is not registered yet") + + wrapped, err := dial.WrapConn(conn) + require.ErrorIs(t, err, ErrSwept) + require.Nil(t, wrapped) + + buf := make([]byte, 1) + _, err = conn.Read(buf) + assert.Error(t, err, "the old-network connection must be closed, not leaked") + + assert.Equal(t, 0, sweeper.sweepAll(), "nothing may leak into the next sweep") +} + +func TestMarkNetworkChangeSparesFreshConns(t *testing.T) { + sweeper := NewWithConfig(Config{SweepDelay: 10 * time.Millisecond}) + + stale := wrap(t, sweeper, connPair(t)) + sweeper.MarkNetworkChange() + _ = wrap(t, sweeper, connPair(t)) + + _ = stale.SetReadDeadline(time.Now().Add(time.Second)) + buf := make([]byte, 1) + _, err := stale.Read(buf) + require.ErrorIs(t, err, net.ErrClosed, "stale connection must be closed by the delayed sweep") + + assert.Equal(t, 1, sweeper.sweepAll(), "the fresh connection must survive the stale sweep") +} + +func TestMarkNetworkChangeAbortsStaleDials(t *testing.T) { + sweeper := NewWithConfig(Config{SweepDelay: 10 * time.Millisecond}) + + stale := sweeper.StartDial(context.Background()) + defer stale.Release() + sweeper.MarkNetworkChange() + fresh := sweeper.StartDial(context.Background()) + defer fresh.Release() + + assert.Eventually(t, func() bool { + return stale.Ctx().Err() != nil + }, time.Second, 5*time.Millisecond, "stale dial must be aborted by the delayed sweep") + assert.NoError(t, fresh.Ctx().Err(), "post-mark dial must not be aborted") +} + +func TestConnInheritsDialGeneration(t *testing.T) { + sweeper := NewWithConfig(Config{SweepDelay: 20 * time.Millisecond}) + + // The dial starts before the network change but completes after it: the + // socket is bound to the old network, so the sweep must still cut it. + dial := sweeper.StartDial(context.Background()) + defer dial.Release() + sweeper.MarkNetworkChange() + + wrapped, err := dial.WrapConn(connPair(t)) + require.NoError(t, err) + + _ = wrapped.SetReadDeadline(time.Now().Add(time.Second)) + buf := make([]byte, 1) + _, err = wrapped.Read(buf) + require.ErrorIs(t, err, net.ErrClosed, "old-generation connection must be swept") +} + +func TestRepeatedMarksCoalesce(t *testing.T) { + sweeper := NewWithConfig(Config{SweepDelay: 20 * time.Millisecond}) + + first := wrap(t, sweeper, connPair(t)) + sweeper.MarkNetworkChange() + second := wrap(t, sweeper, connPair(t)) + sweeper.MarkNetworkChange() + _ = wrap(t, sweeper, connPair(t)) + + buf := make([]byte, 1) + for _, conn := range []net.Conn{first, second} { + _ = conn.SetReadDeadline(time.Now().Add(time.Second)) + _, err := conn.Read(buf) + require.ErrorIs(t, err, net.ErrClosed, "every pre-mark connection must be swept by the rescheduled sweep") + } + assert.Equal(t, 1, sweeper.sweepAll(), "only the newest-generation connection may remain") +} + +func TestNilSweeperIsNoop(t *testing.T) { + var sweeper *Sweeper + + conn := connPair(t) + dial := sweeper.StartDial(context.Background()) + defer dial.Release() + + wrapped, err := dial.WrapConn(conn) + require.NoError(t, err) + assert.Equal(t, conn, wrapped, "nil sweeper must return the conn unchanged") + assert.NoError(t, dial.Ctx().Err(), "nil sweeper must not cancel the dial context") + assert.Equal(t, 0, sweeper.sweepAll(), "nil sweeper closes nothing") +} + +// wrap registers conn with the sweeper through a completed dial. +func wrap(t *testing.T, sweeper *Sweeper, conn net.Conn) net.Conn { + t.Helper() + + dial := sweeper.StartDial(context.Background()) + defer dial.Release() + + wrapped, err := dial.WrapConn(conn) + require.NoError(t, err) + return wrapped +} + +// connPair dials a loopback TCP connection and keeps the accepted peer open +// until the test ends: a peer that closed early would make the connection +// unreadable on its own, so a read error after the sweep would prove nothing. +func connPair(t *testing.T) net.Conn { + t.Helper() + + l, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { + if err := l.Close(); err != nil { + t.Logf("listener close error: %v", err) + } + }) + + accepted := make(chan net.Conn, 1) + go func() { + conn, err := l.Accept() + if err != nil { + close(accepted) + return + } + accepted <- conn + }() + + conn, err := net.Dial("tcp", l.Addr().String()) + require.NoError(t, err) + + peer, ok := <-accepted + require.True(t, ok, "listener must accept the dialed connection") + t.Cleanup(func() { + if err := peer.Close(); err != nil { + t.Logf("peer close error: %v", err) + } + }) + + return conn +} + +// sweepAll cuts every registration regardless of generation. +func (s *Sweeper) sweepAll() int { + return s.sweep(math.MaxUint64) +} diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index 6fbb09958..7f3ce1bbf 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -343,8 +343,13 @@ type LoginRequest struct { DisableSSHAuth *bool `protobuf:"varint,38,opt,name=disableSSHAuth,proto3,oneof" json:"disableSSHAuth,omitempty"` SshJWTCacheTTL *int32 `protobuf:"varint,39,opt,name=sshJWTCacheTTL,proto3,oneof" json:"sshJWTCacheTTL,omitempty"` DisableIpv6 *bool `protobuf:"varint,40,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + EnableLocalMetrics *bool `protobuf:"varint,41,opt,name=enable_local_metrics,json=enableLocalMetrics,proto3,oneof" json:"enable_local_metrics,omitempty"` + LocalMetricsAddress *string `protobuf:"bytes,42,opt,name=local_metrics_address,json=localMetricsAddress,proto3,oneof" json:"local_metrics_address,omitempty"` + // remoteJobsAllowed opts the peer into management-requested remote jobs + // (e.g. debug bundles). Absent leaves the stored value unchanged. + RemoteJobsAllowed *bool `protobuf:"varint,43,opt,name=remoteJobsAllowed,proto3,oneof" json:"remoteJobsAllowed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *LoginRequest) Reset() { @@ -658,6 +663,27 @@ func (x *LoginRequest) GetDisableIpv6() bool { return false } +func (x *LoginRequest) GetEnableLocalMetrics() bool { + if x != nil && x.EnableLocalMetrics != nil { + return *x.EnableLocalMetrics + } + return false +} + +func (x *LoginRequest) GetLocalMetricsAddress() string { + if x != nil && x.LocalMetricsAddress != nil { + return *x.LocalMetricsAddress + } + return "" +} + +func (x *LoginRequest) GetRemoteJobsAllowed() bool { + if x != nil && x.RemoteJobsAllowed != nil { + return *x.RemoteJobsAllowed + } + return false +} + type LoginResponse struct { state protoimpl.MessageState `protogen:"open.v1"` NeedsSSOLogin bool `protobuf:"varint,1,opt,name=needsSSOLogin,proto3" json:"needsSSOLogin,omitempty"` @@ -1215,6 +1241,7 @@ type GetConfigResponse struct { DisableSSHAuth bool `protobuf:"varint,25,opt,name=disableSSHAuth,proto3" json:"disableSSHAuth,omitempty"` SshJWTCacheTTL int32 `protobuf:"varint,26,opt,name=sshJWTCacheTTL,proto3" json:"sshJWTCacheTTL,omitempty"` DisableIpv6 bool `protobuf:"varint,27,opt,name=disable_ipv6,json=disableIpv6,proto3" json:"disable_ipv6,omitempty"` + RemoteJobsAllowed bool `protobuf:"varint,29,opt,name=remoteJobsAllowed,proto3" json:"remoteJobsAllowed,omitempty"` // mDMManagedFields lists the names of configuration keys whose value is // currently enforced by an MDM policy. Names match mdm.Key* constants // (e.g. "managementURL", "disableClientRoutes"). UI/CLI clients should @@ -1444,6 +1471,13 @@ func (x *GetConfigResponse) GetDisableIpv6() bool { return false } +func (x *GetConfigResponse) GetRemoteJobsAllowed() bool { + if x != nil { + return x.RemoteJobsAllowed + } + return false +} + func (x *GetConfigResponse) GetMDMManagedFields() []string { if x != nil { return x.MDMManagedFields @@ -2771,14 +2805,23 @@ func (x *ForwardingRulesResponse) GetRules() []*ForwardingRule { // DebugBundler type DebugBundleRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Anonymize bool `protobuf:"varint,1,opt,name=anonymize,proto3" json:"anonymize,omitempty"` - SystemInfo bool `protobuf:"varint,3,opt,name=systemInfo,proto3" json:"systemInfo,omitempty"` - UploadURL string `protobuf:"bytes,4,opt,name=uploadURL,proto3" json:"uploadURL,omitempty"` - LogFileCount uint32 `protobuf:"varint,5,opt,name=logFileCount,proto3" json:"logFileCount,omitempty"` - CliVersion string `protobuf:"bytes,6,opt,name=cliVersion,proto3" json:"cliVersion,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Anonymize bool `protobuf:"varint,1,opt,name=anonymize,proto3" json:"anonymize,omitempty"` + SystemInfo bool `protobuf:"varint,3,opt,name=systemInfo,proto3" json:"systemInfo,omitempty"` + UploadURL string `protobuf:"bytes,4,opt,name=uploadURL,proto3" json:"uploadURL,omitempty"` + LogFileCount uint32 `protobuf:"varint,5,opt,name=logFileCount,proto3" json:"logFileCount,omitempty"` + CliVersion string `protobuf:"bytes,6,opt,name=cliVersion,proto3" json:"cliVersion,omitempty"` + // uploadInsecure allows uploading to an http endpoint or one with an + // untrusted TLS certificate. Restricted to privileged callers; for + // self-hosted upload servers. + UploadInsecure bool `protobuf:"varint,7,opt,name=uploadInsecure,proto3" json:"uploadInsecure,omitempty"` + // anonymizeLevel selects how much the anonymizer redacts: "default" + // (or empty) keeps internal IP ranges, "strict" also anonymizes them. + // Unknown values are treated as "strict". Only meaningful with anonymize; + // "strict" implies it. + AnonymizeLevel string `protobuf:"bytes,8,opt,name=anonymizeLevel,proto3" json:"anonymizeLevel,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DebugBundleRequest) Reset() { @@ -2846,6 +2889,20 @@ func (x *DebugBundleRequest) GetCliVersion() string { return "" } +func (x *DebugBundleRequest) GetUploadInsecure() bool { + if x != nil { + return x.UploadInsecure + } + return false +} + +func (x *DebugBundleRequest) GetAnonymizeLevel() string { + if x != nil { + return x.AnonymizeLevel + } + return "" +} + type DebugBundleResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` @@ -4210,8 +4267,13 @@ type SetConfigRequest struct { DisableSSHAuth *bool `protobuf:"varint,33,opt,name=disableSSHAuth,proto3,oneof" json:"disableSSHAuth,omitempty"` SshJWTCacheTTL *int32 `protobuf:"varint,34,opt,name=sshJWTCacheTTL,proto3,oneof" json:"sshJWTCacheTTL,omitempty"` DisableIpv6 *bool `protobuf:"varint,35,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + EnableLocalMetrics *bool `protobuf:"varint,36,opt,name=enable_local_metrics,json=enableLocalMetrics,proto3,oneof" json:"enable_local_metrics,omitempty"` + LocalMetricsAddress *string `protobuf:"bytes,37,opt,name=local_metrics_address,json=localMetricsAddress,proto3,oneof" json:"local_metrics_address,omitempty"` + // remoteJobsAllowed opts the peer into management-requested remote jobs + // (e.g. debug bundles). Absent leaves the stored value unchanged. + RemoteJobsAllowed *bool `protobuf:"varint,38,opt,name=remoteJobsAllowed,proto3,oneof" json:"remoteJobsAllowed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SetConfigRequest) Reset() { @@ -4489,6 +4551,27 @@ func (x *SetConfigRequest) GetDisableIpv6() bool { return false } +func (x *SetConfigRequest) GetEnableLocalMetrics() bool { + if x != nil && x.EnableLocalMetrics != nil { + return *x.EnableLocalMetrics + } + return false +} + +func (x *SetConfigRequest) GetLocalMetricsAddress() string { + if x != nil && x.LocalMetricsAddress != nil { + return *x.LocalMetricsAddress + } + return "" +} + +func (x *SetConfigRequest) GetRemoteJobsAllowed() bool { + if x != nil && x.RemoteJobsAllowed != nil { + return *x.RemoteJobsAllowed + } + return false +} + type SetConfigResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -5605,9 +5688,13 @@ func (x *GetPeerSSHHostKeyResponse) GetFound() bool { type RequestJWTAuthRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // hint for OIDC login_hint parameter (typically email address) - Hint *string `protobuf:"bytes,1,opt,name=hint,proto3,oneof" json:"hint,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Hint *string `protobuf:"bytes,1,opt,name=hint,proto3,oneof" json:"hint,omitempty"` + // hasGraphicalSession tells the daemon that the caller has a graphical session, + // which decides whether PKCE or the device code flow is preferred. The daemon + // cannot detect this itself: it does not inherit the session environment. + HasGraphicalSession bool `protobuf:"varint,2,opt,name=hasGraphicalSession,proto3" json:"hasGraphicalSession,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RequestJWTAuthRequest) Reset() { @@ -5647,6 +5734,13 @@ func (x *RequestJWTAuthRequest) GetHint() string { return "" } +func (x *RequestJWTAuthRequest) GetHasGraphicalSession() bool { + if x != nil { + return x.HasGraphicalSession + } + return false +} + // RequestJWTAuthResponse contains authentication flow information type RequestJWTAuthResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -5871,9 +5965,13 @@ type RequestExtendAuthSessionRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Optional OIDC login_hint (typically the user's email) to pre-fill the // IdP login form. - Hint *string `protobuf:"bytes,1,opt,name=hint,proto3,oneof" json:"hint,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Hint *string `protobuf:"bytes,1,opt,name=hint,proto3,oneof" json:"hint,omitempty"` + // hasGraphicalSession tells the daemon that the caller has a graphical session, + // which decides whether PKCE or the device code flow is preferred. The daemon + // cannot detect this itself: it does not inherit the session environment. + HasGraphicalSession bool `protobuf:"varint,2,opt,name=hasGraphicalSession,proto3" json:"hasGraphicalSession,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RequestExtendAuthSessionRequest) Reset() { @@ -5913,6 +6011,13 @@ func (x *RequestExtendAuthSessionRequest) GetHint() string { return "" } +func (x *RequestExtendAuthSessionRequest) GetHasGraphicalSession() bool { + if x != nil { + return x.HasGraphicalSession + } + return false +} + // RequestExtendAuthSessionResponse carries the verification URI the UI // should open in a browser. The daemon retains the flow state and resolves // it via WaitExtendAuthSession. @@ -6987,7 +7092,7 @@ var File_daemon_proto protoreflect.FileDescriptor const file_daemon_proto_rawDesc = "" + "\n" + "\fdaemon.proto\x12\x06daemon\x1a google/protobuf/descriptor.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/duration.proto\"\x0e\n" + - "\fEmptyRequest\"\xef\x12\n" + + "\fEmptyRequest\"\xdb\x14\n" + "\fLoginRequest\x12\x1a\n" + "\bsetupKey\x18\x01 \x01(\tR\bsetupKey\x12&\n" + "\fpreSharedKey\x18\x02 \x01(\tB\x02\x18\x01R\fpreSharedKey\x12$\n" + @@ -7032,7 +7137,10 @@ const file_daemon_proto_rawDesc = "" + "\x1denableSSHRemotePortForwarding\x18% \x01(\bH\x18R\x1denableSSHRemotePortForwarding\x88\x01\x01\x12+\n" + "\x0edisableSSHAuth\x18& \x01(\bH\x19R\x0edisableSSHAuth\x88\x01\x01\x12+\n" + "\x0esshJWTCacheTTL\x18' \x01(\x05H\x1aR\x0esshJWTCacheTTL\x88\x01\x01\x12&\n" + - "\fdisable_ipv6\x18( \x01(\bH\x1bR\vdisableIpv6\x88\x01\x01B\x13\n" + + "\fdisable_ipv6\x18( \x01(\bH\x1bR\vdisableIpv6\x88\x01\x01\x125\n" + + "\x14enable_local_metrics\x18) \x01(\bH\x1cR\x12enableLocalMetrics\x88\x01\x01\x127\n" + + "\x15local_metrics_address\x18* \x01(\tH\x1dR\x13localMetricsAddress\x88\x01\x01\x121\n" + + "\x11remoteJobsAllowed\x18+ \x01(\bH\x1eR\x11remoteJobsAllowed\x88\x01\x01B\x13\n" + "\x11_rosenpassEnabledB\x10\n" + "\x0e_interfaceNameB\x10\n" + "\x0e_wireguardPortB\x17\n" + @@ -7060,7 +7168,10 @@ const file_daemon_proto_rawDesc = "" + "\x1e_enableSSHRemotePortForwardingB\x11\n" + "\x0f_disableSSHAuthB\x11\n" + "\x0f_sshJWTCacheTTLB\x0f\n" + - "\r_disable_ipv6\"\xb5\x01\n" + + "\r_disable_ipv6B\x17\n" + + "\x15_enable_local_metricsB\x18\n" + + "\x16_local_metrics_addressB\x14\n" + + "\x12_remoteJobsAllowed\"\xb5\x01\n" + "\rLoginResponse\x12$\n" + "\rneedsSSOLogin\x18\x01 \x01(\bR\rneedsSSOLogin\x12\x1a\n" + "\buserCode\x18\x02 \x01(\tR\buserCode\x12(\n" + @@ -7095,7 +7206,7 @@ const file_daemon_proto_rawDesc = "" + "\fDownResponse\"P\n" + "\x10GetConfigRequest\x12 \n" + "\vprofileName\x18\x01 \x01(\tR\vprofileName\x12\x1a\n" + - "\busername\x18\x02 \x01(\tR\busername\"\xaa\t\n" + + "\busername\x18\x02 \x01(\tR\busername\"\xd8\t\n" + "\x11GetConfigResponse\x12$\n" + "\rmanagementUrl\x18\x01 \x01(\tR\rmanagementUrl\x12\x1e\n" + "\n" + @@ -7127,7 +7238,8 @@ const file_daemon_proto_rawDesc = "" + "\x1denableSSHRemotePortForwarding\x18\x17 \x01(\bR\x1denableSSHRemotePortForwarding\x12&\n" + "\x0edisableSSHAuth\x18\x19 \x01(\bR\x0edisableSSHAuth\x12&\n" + "\x0esshJWTCacheTTL\x18\x1a \x01(\x05R\x0esshJWTCacheTTL\x12!\n" + - "\fdisable_ipv6\x18\x1b \x01(\bR\vdisableIpv6\x12*\n" + + "\fdisable_ipv6\x18\x1b \x01(\bR\vdisableIpv6\x12,\n" + + "\x11remoteJobsAllowed\x18\x1d \x01(\bR\x11remoteJobsAllowed\x12*\n" + "\x10mDMManagedFields\x18\x1c \x03(\tR\x10mDMManagedFields\"\x92\x06\n" + "\tPeerState\x12\x0e\n" + "\x02IP\x18\x01 \x01(\tR\x02IP\x12\x16\n" + @@ -7242,7 +7354,7 @@ const file_daemon_proto_rawDesc = "" + "\x12translatedHostname\x18\x04 \x01(\tR\x12translatedHostname\x128\n" + "\x0etranslatedPort\x18\x05 \x01(\v2\x10.daemon.PortInfoR\x0etranslatedPort\"G\n" + "\x17ForwardingRulesResponse\x12,\n" + - "\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\xb4\x01\n" + + "\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\x84\x02\n" + "\x12DebugBundleRequest\x12\x1c\n" + "\tanonymize\x18\x01 \x01(\bR\tanonymize\x12\x1e\n" + "\n" + @@ -7252,7 +7364,9 @@ const file_daemon_proto_rawDesc = "" + "\flogFileCount\x18\x05 \x01(\rR\flogFileCount\x12\x1e\n" + "\n" + "cliVersion\x18\x06 \x01(\tR\n" + - "cliVersion\"}\n" + + "cliVersion\x12&\n" + + "\x0euploadInsecure\x18\a \x01(\bR\x0euploadInsecure\x12&\n" + + "\x0eanonymizeLevel\x18\b \x01(\tR\x0eanonymizeLevel\"}\n" + "\x13DebugBundleResponse\x12\x12\n" + "\x04path\x18\x01 \x01(\tR\x04path\x12 \n" + "\vuploadedKey\x18\x02 \x01(\tR\vuploadedKey\x120\n" + @@ -7353,7 +7467,7 @@ const file_daemon_proto_rawDesc = "" + "\f_profileNameB\v\n" + "\t_username\"'\n" + "\x15SwitchProfileResponse\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\"\x98\x11\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"\x84\x13\n" + "\x10SetConfigRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\x12 \n" + "\vprofileName\x18\x02 \x01(\tR\vprofileName\x12$\n" + @@ -7393,7 +7507,10 @@ const file_daemon_proto_rawDesc = "" + "\x1denableSSHRemotePortForwarding\x18 \x01(\bH\x15R\x1denableSSHRemotePortForwarding\x88\x01\x01\x12+\n" + "\x0edisableSSHAuth\x18! \x01(\bH\x16R\x0edisableSSHAuth\x88\x01\x01\x12+\n" + "\x0esshJWTCacheTTL\x18\" \x01(\x05H\x17R\x0esshJWTCacheTTL\x88\x01\x01\x12&\n" + - "\fdisable_ipv6\x18# \x01(\bH\x18R\vdisableIpv6\x88\x01\x01B\x13\n" + + "\fdisable_ipv6\x18# \x01(\bH\x18R\vdisableIpv6\x88\x01\x01\x125\n" + + "\x14enable_local_metrics\x18$ \x01(\bH\x19R\x12enableLocalMetrics\x88\x01\x01\x127\n" + + "\x15local_metrics_address\x18% \x01(\tH\x1aR\x13localMetricsAddress\x88\x01\x01\x121\n" + + "\x11remoteJobsAllowed\x18& \x01(\bH\x1bR\x11remoteJobsAllowed\x88\x01\x01B\x13\n" + "\x11_rosenpassEnabledB\x10\n" + "\x0e_interfaceNameB\x10\n" + "\x0e_wireguardPortB\x17\n" + @@ -7418,7 +7535,10 @@ const file_daemon_proto_rawDesc = "" + "\x1e_enableSSHRemotePortForwardingB\x11\n" + "\x0f_disableSSHAuthB\x11\n" + "\x0f_sshJWTCacheTTLB\x0f\n" + - "\r_disable_ipv6\"\x13\n" + + "\r_disable_ipv6B\x17\n" + + "\x15_enable_local_metricsB\x18\n" + + "\x16_local_metrics_addressB\x14\n" + + "\x12_remoteJobsAllowed\"\x13\n" + "\x11SetConfigResponse\"Q\n" + "\x11AddProfileRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\x12 \n" + @@ -7478,9 +7598,10 @@ const file_daemon_proto_rawDesc = "" + "sshHostKey\x12\x16\n" + "\x06peerIP\x18\x02 \x01(\tR\x06peerIP\x12\x1a\n" + "\bpeerFQDN\x18\x03 \x01(\tR\bpeerFQDN\x12\x14\n" + - "\x05found\x18\x04 \x01(\bR\x05found\"9\n" + + "\x05found\x18\x04 \x01(\bR\x05found\"k\n" + "\x15RequestJWTAuthRequest\x12\x17\n" + - "\x04hint\x18\x01 \x01(\tH\x00R\x04hint\x88\x01\x01B\a\n" + + "\x04hint\x18\x01 \x01(\tH\x00R\x04hint\x88\x01\x01\x120\n" + + "\x13hasGraphicalSession\x18\x02 \x01(\bR\x13hasGraphicalSessionB\a\n" + "\x05_hint\"\x9a\x02\n" + "\x16RequestJWTAuthResponse\x12(\n" + "\x0fverificationURI\x18\x01 \x01(\tR\x0fverificationURI\x128\n" + @@ -7500,9 +7621,10 @@ const file_daemon_proto_rawDesc = "" + "\x14WaitJWTTokenResponse\x12\x14\n" + "\x05token\x18\x01 \x01(\tR\x05token\x12\x1c\n" + "\ttokenType\x18\x02 \x01(\tR\ttokenType\x12\x1c\n" + - "\texpiresIn\x18\x03 \x01(\x03R\texpiresIn\"C\n" + + "\texpiresIn\x18\x03 \x01(\x03R\texpiresIn\"u\n" + "\x1fRequestExtendAuthSessionRequest\x12\x17\n" + - "\x04hint\x18\x01 \x01(\tH\x00R\x04hint\x88\x01\x01B\a\n" + + "\x04hint\x18\x01 \x01(\tH\x00R\x04hint\x88\x01\x01\x120\n" + + "\x13hasGraphicalSession\x18\x02 \x01(\bR\x13hasGraphicalSessionB\a\n" + "\x05_hint\"\xe0\x01\n" + " RequestExtendAuthSessionResponse\x12(\n" + "\x0fverificationURI\x18\x01 \x01(\tR\x0fverificationURI\x128\n" + diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index 8d5294eb7..3953f9c15 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -242,6 +242,12 @@ message LoginRequest { optional bool disableSSHAuth = 38; optional int32 sshJWTCacheTTL = 39; optional bool disable_ipv6 = 40; + + optional bool enable_local_metrics = 41; + optional string local_metrics_address = 42; + // remoteJobsAllowed opts the peer into management-requested remote jobs + // (e.g. debug bundles). Absent leaves the stored value unchanged. + optional bool remoteJobsAllowed = 43; } message LoginResponse { @@ -362,6 +368,8 @@ message GetConfigResponse { bool disable_ipv6 = 27; + bool remoteJobsAllowed = 29; + // mDMManagedFields lists the names of configuration keys whose value is // currently enforced by an MDM policy. Names match mdm.Key* constants // (e.g. "managementURL", "disableClientRoutes"). UI/CLI clients should @@ -536,6 +544,15 @@ message DebugBundleRequest { string uploadURL = 4; uint32 logFileCount = 5; string cliVersion = 6; + // uploadInsecure allows uploading to an http endpoint or one with an + // untrusted TLS certificate. Restricted to privileged callers; for + // self-hosted upload servers. + bool uploadInsecure = 7; + // anonymizeLevel selects how much the anonymizer redacts: "default" + // (or empty) keeps internal IP ranges, "strict" also anonymizes them. + // Unknown values are treated as "strict". Only meaningful with anonymize; + // "strict" implies it. + string anonymizeLevel = 8; } message DebugBundleResponse { @@ -757,6 +774,12 @@ message SetConfigRequest { optional bool disableSSHAuth = 33; optional int32 sshJWTCacheTTL = 34; optional bool disable_ipv6 = 35; + + optional bool enable_local_metrics = 36; + optional string local_metrics_address = 37; + // remoteJobsAllowed opts the peer into management-requested remote jobs + // (e.g. debug bundles). Absent leaves the stored value unchanged. + optional bool remoteJobsAllowed = 38; } message SetConfigResponse{} @@ -885,6 +908,10 @@ message GetPeerSSHHostKeyResponse { message RequestJWTAuthRequest { // hint for OIDC login_hint parameter (typically email address) optional string hint = 1; + // hasGraphicalSession tells the daemon that the caller has a graphical session, + // which decides whether PKCE or the device code flow is preferred. The daemon + // cannot detect this itself: it does not inherit the session environment. + bool hasGraphicalSession = 2; } // RequestJWTAuthResponse contains authentication flow information @@ -928,6 +955,10 @@ message RequestExtendAuthSessionRequest { // Optional OIDC login_hint (typically the user's email) to pre-fill the // IdP login form. optional string hint = 1; + // hasGraphicalSession tells the daemon that the caller has a graphical session, + // which decides whether PKCE or the device code flow is preferred. The daemon + // cannot detect this itself: it does not inherit the session environment. + bool hasGraphicalSession = 2; } // RequestExtendAuthSessionResponse carries the verification URI the UI diff --git a/client/proto/generate.sh b/client/proto/generate.sh index cea8ae912..d73367d12 100755 --- a/client/proto/generate.sh +++ b/client/proto/generate.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash set -e if ! which realpath >/dev/null 2>&1; then diff --git a/client/server/debug.go b/client/server/debug.go index 0b6ac4b53..8f4a506b4 100644 --- a/client/server/debug.go +++ b/client/server/debug.go @@ -7,18 +7,63 @@ import ( "context" "errors" "fmt" + "path/filepath" "runtime/pprof" + "strings" + "time" log "github.com/sirupsen/logrus" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + "github.com/netbirdio/netbird/client/anonymize" "github.com/netbirdio/netbird/client/internal/debug" + "github.com/netbirdio/netbird/client/internal/ipcauth" "github.com/netbirdio/netbird/client/proto" mgmProto "github.com/netbirdio/netbird/shared/management/proto" "github.com/netbirdio/netbird/version" ) // DebugBundle creates a debug bundle and returns the location. -func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (resp *proto.DebugBundleResponse, err error) { +func (s *Server) DebugBundle(callerCtx context.Context, req *proto.DebugBundleRequest) (resp *proto.DebugBundleResponse, err error) { + if err := requirePrivilegeForUploadURL(callerCtx, req.GetUploadURL(), req.GetUploadInsecure()); err != nil { + return nil, err + } + + // The UI log is opened as whoever asked for this bundle, so a caller only + // collects a log it owns (privileged callers excepted). ok is false on a + // socket that carries no identity, which skips the UI log. + callerID, callerIdentified := ipcauth.CallerIdentity(callerCtx) + + path, managementURL, err := s.generateDebugBundle(req, uiLogOpener(callerID, callerIdentified)) + if err != nil { + return nil, err + } + + if req.GetUploadURL() == "" { + return &proto.DebugBundleResponse{Path: path}, nil + } + + // The upload runs without s.mutex held: it does network I/O to a possibly + // slow destination and must not block the other RPCs that take the lock. The + // bounded context is a backstop against a hung connection. + uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + key, err := debug.UploadDebugBundle(uploadCtx, req.GetUploadURL(), managementURL, path, req.GetUploadInsecure()) + if err != nil { + log.Errorf("failed to upload debug bundle to %s: %v", req.GetUploadURL(), err) + return &proto.DebugBundleResponse{Path: path, UploadFailureReason: err.Error()}, nil + } + + log.Infof("debug bundle uploaded to %s with key %s", req.GetUploadURL(), key) + + return &proto.DebugBundleResponse{Path: path, UploadedKey: key}, nil +} + +// generateDebugBundle builds the bundle under s.mutex and returns its path plus +// the management URL captured under the lock, so the caller can run the upload +// without holding the lock. +func (s *Server) generateDebugBundle(req *proto.DebugBundleRequest, uiOpener debug.LogOpener) (path string, managementURL string, err error) { s.mutex.Lock() defer s.mutex.Unlock() @@ -68,6 +113,7 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) ( SyncResponse: syncResponse, LogPath: s.logFile, UILogPath: s.uiLogPath, + UILogOpener: uiOpener, CPUProfile: cpuProfileData, CapturePath: capturePath, RefreshStatus: refreshStatus, @@ -77,28 +123,22 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) ( }, debug.BundleConfig{ Anonymize: req.GetAnonymize(), + AnonymizeLevel: anonymize.ParseLevel(req.GetAnonymizeLevel()), IncludeSystemInfo: req.GetSystemInfo(), LogFileCount: req.GetLogFileCount(), }, ) - path, err := bundleGenerator.Generate() + path, err = bundleGenerator.Generate() if err != nil { - return nil, fmt.Errorf("generate debug bundle: %w", err) + return "", "", fmt.Errorf("generate debug bundle: %w", err) } - if req.GetUploadURL() == "" { - return &proto.DebugBundleResponse{Path: path}, nil - } - key, err := debug.UploadDebugBundle(context.Background(), req.GetUploadURL(), s.config.ManagementURL.String(), path) - if err != nil { - log.Errorf("failed to upload debug bundle to %s: %v", req.GetUploadURL(), err) - return &proto.DebugBundleResponse{Path: path, UploadFailureReason: err.Error()}, nil + if s.config != nil && s.config.ManagementURL != nil { + managementURL = s.config.ManagementURL.String() } - log.Infof("debug bundle uploaded to %s with key %s", req.GetUploadURL(), key) - - return &proto.DebugBundleResponse{Path: path, UploadedKey: key}, nil + return path, managementURL, nil } // GetLogLevel gets the current logging level for the server. @@ -138,12 +178,34 @@ func (s *Server) SetLogLevel(_ context.Context, req *proto.SetLogLevelRequest) ( // RegisterUILog records the desktop UI's absolute log path so DebugBundle can // collect the GUI log. The daemon runs as root and can't resolve the user's // config dir, so the UI reports it. Last-writer-wins (one UI per socket). -func (s *Server) RegisterUILog(_ context.Context, req *proto.RegisterUILogRequest) (*proto.RegisterUILogResponse, error) { +// +// The path arrives over an IPC any local user can reach and is later opened by +// a root daemon, so it is constrained to the file name the UI writes and to a +// local absolute path. Authorization happens when DebugBundle opens it: the +// bundle refuses a file its requester does not own. A caller the daemon cannot +// identify cannot register a path at all. +func (s *Server) RegisterUILog(callerCtx context.Context, req *proto.RegisterUILogRequest) (*proto.RegisterUILogResponse, error) { + if _, ok := ipcauth.CallerIdentity(callerCtx); !ok { + return nil, gstatus.Error(codes.PermissionDenied, + "registering a UI log path requires a control channel that carries the caller's identity") + } + + path := filepath.Clean(req.GetPath()) + if !filepath.IsAbs(path) || filepath.Base(path) != uiLogFileName { + return nil, gstatus.Errorf(codes.InvalidArgument, "UI log path must be an absolute path ending in %s", uiLogFileName) + } + // filepath.IsAbs accepts a Windows UNC path (\\host\share\...) and a device + // path (\\.\, \\?\); opening one would make the root daemon reach a remote + // or device namespace. Require a plain local path. + if strings.HasPrefix(path, `\\`) { + return nil, gstatus.Error(codes.InvalidArgument, "UI log path must be a local path, not a UNC or device path") + } + s.mutex.Lock() defer s.mutex.Unlock() - s.uiLogPath = req.GetPath() - log.Infof("registered UI log path: %s", s.uiLogPath) + s.uiLogPath = path + log.Infof("registered UI log path %s", s.uiLogPath) return &proto.RegisterUILogResponse{}, nil } diff --git a/client/server/debug_gate.go b/client/server/debug_gate.go new file mode 100644 index 000000000..983a13aaf --- /dev/null +++ b/client/server/debug_gate.go @@ -0,0 +1,99 @@ +//go:build !android && !ios + +package server + +import ( + "context" + "fmt" + "net/url" + "os" + "strings" + + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/configs" + "github.com/netbirdio/netbird/client/internal/debug" + "github.com/netbirdio/netbird/client/internal/ipcauth" + "github.com/netbirdio/netbird/upload-server/types" +) + +// uiLogFileName is the only file name the daemon accepts as a UI log path. The +// UI (writer), this validation, and the bundle collector all read it from +// configs so they cannot drift. +const uiLogFileName = configs.UILogFile + +// uiLogOpener opens the registered UI log, and its rotated siblings, on behalf +// of the caller requesting the bundle: OpenOwnedFile then collects the log only +// when that caller owns it (or is privileged). identified is false on a socket +// that carries no caller identity, in which case nothing is opened. +func uiLogOpener(id ipcauth.Identity, identified bool) debug.LogOpener { + return func(path string) (*os.File, error) { + if !identified { + return nil, fmt.Errorf("bundle requester has no verified identity") + } + return ipcauth.OpenOwnedFile(id, path) + } +} + +// requirePrivilegeForUploadURL restricts where the daemon may send a debug +// bundle. The bundle holds the daemon's own logs and state, and the daemon +// fetches the upload URL itself, so an unrestricted endpoint turns the daemon +// into both an exfiltration channel and a request forwarder that reaches +// services only it can talk to. +// +// The upload service NetBird publishes is open to any caller, since that is what +// the CLI and the desktop UI use. Any other endpoint, self-hosted upload servers +// included, requires a privileged caller. Plaintext is refused for everyone: the +// daemon fetches the URL and then PUTs the bundle to whatever that fetch returns, +// so an http hop is a place to intercept the bundle or the redirect. +// +// insecure relaxes transport security (http, or an untrusted TLS certificate) +// for a self-hosted server. It weakens a root-privileged upload, so it is +// refused for an unprivileged caller regardless of the host. +func requirePrivilegeForUploadURL(ctx context.Context, rawURL string, insecure bool) error { + if rawURL == "" { + return nil + } + + parsed, err := url.Parse(rawURL) + if err != nil { + return gstatus.Errorf(codes.InvalidArgument, "parse upload URL: %v", err) + } + + // --insecure relaxes https to http or an untrusted certificate; it does not + // widen the URL to arbitrary schemes, so a host and http/https are required + // before the insecure branch takes over. + if parsed.Host == "" || (parsed.Scheme != "https" && parsed.Scheme != "http") { + return gstatus.Errorf(codes.InvalidArgument, "upload URL must be http or https with a host") + } + + if insecure { + return denyPrivileged(ctx, + "uploading a debug bundle without transport security (--upload-bundle-insecure)", + ipcauth.ElevatedCommand("netbird debug bundle -U --upload-bundle-insecure --upload-bundle-url ")) + } + + if parsed.Scheme != "https" { + return gstatus.Errorf(codes.InvalidArgument, "upload URL must use https, got scheme %q", parsed.Scheme) + } + + if isDefaultUploadService(parsed) { + return nil + } + + return denyPrivileged(ctx, + "uploading a debug bundle to an upload service other than the default one", + ipcauth.ElevatedCommand("netbird debug bundle -U --upload-bundle-url ")) +} + +// isDefaultUploadService reports whether the URL points at the upload service +// NetBird runs. Only the host is compared: the service's path may differ between +// releases, and the host is what decides who receives the bundle. +func isDefaultUploadService(parsed *url.URL) bool { + defaultURL, err := url.Parse(types.DefaultBundleURL) + if err != nil { + return false + } + return parsed.Scheme == defaultURL.Scheme && strings.EqualFold(parsed.Host, defaultURL.Host) +} diff --git a/client/server/debug_gate_test.go b/client/server/debug_gate_test.go new file mode 100644 index 000000000..e958fc581 --- /dev/null +++ b/client/server/debug_gate_test.go @@ -0,0 +1,157 @@ +//go:build !android && !ios + +package server + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal/ipcauth" + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/upload-server/types" +) + +func TestRegisterUILogRefusesUnidentifiedCaller(t *testing.T) { + s := &Server{} + + _, err := s.RegisterUILog(noIdentityCtx(), &proto.RegisterUILogRequest{ + Path: filepath.Join(t.TempDir(), uiLogFileName), + }) + + if gstatus.Code(err) != codes.PermissionDenied { + t.Fatalf("code = %v, want PermissionDenied", gstatus.Code(err)) + } +} + +func TestRegisterUILogRefusesForeignPath(t *testing.T) { + secret := "/etc/shadow" + if runtime.GOOS == "windows" { + secret = `C:\Windows\System32\config\SAM` + } + + tests := []struct { + name string + path string + }{ + {"empty", ""}, + {"relative", filepath.Join("netbird", uiLogFileName)}, + {"another file", secret}, + {"directory of the log", t.TempDir()}, + {"unc path", `\\attacker\share\` + uiLogFileName}, + {"device path", `\\.\C:\` + uiLogFileName}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s := &Server{} + + _, err := s.RegisterUILog(userCtx(), &proto.RegisterUILogRequest{Path: tc.path}) + + if gstatus.Code(err) != codes.InvalidArgument { + t.Fatalf("code = %v, want InvalidArgument", gstatus.Code(err)) + } + if s.uiLogPath != "" { + t.Fatalf("path %q was recorded despite the refusal", s.uiLogPath) + } + }) + } +} + +func TestRegisterUILogRecordsPath(t *testing.T) { + s := &Server{} + path := filepath.Join(t.TempDir(), uiLogFileName) + + if _, err := s.RegisterUILog(userCtx(), &proto.RegisterUILogRequest{Path: path}); err != nil { + t.Fatalf("register: %v", err) + } + + if s.uiLogPath != path { + t.Fatalf("path = %q, want %q", s.uiLogPath, path) + } +} + +// The UI log is opened as the bundle requester, so a second local user cannot +// collect a log they do not own, and an unidentified requester collects nothing. +func TestUILogOpenerBindsToRequester(t *testing.T) { + path := filepath.Join(t.TempDir(), uiLogFileName) + if err := os.WriteFile(path, []byte("log line"), 0600); err != nil { + t.Fatalf("write log: %v", err) + } + + // A different unprivileged user than the file's owner: refused. + if _, err := uiLogOpener(unprivilegedIdentity(), true)(path); err == nil { + t.Fatal("expected a file the requester does not own to be refused") + } + + // No verified identity: refused. + if _, err := uiLogOpener(ipcauth.Identity{}, false)(path); err == nil { + t.Fatal("expected an unidentified requester to be refused") + } + + // The requester that owns the file: allowed. The test process created it, so + // its own identity is the owner (and a privileged runner is exempt anyway). + owner, err := ipcauth.CurrentProcessIdentity() + if err != nil { + t.Fatalf("current identity: %v", err) + } + f, err := uiLogOpener(owner, true)(path) + if err != nil { + t.Fatalf("expected the owning requester to be allowed, got %v", err) + } + _ = f.Close() +} + +func TestRequirePrivilegeForUploadURL(t *testing.T) { + tests := []struct { + name string + url string + insecure bool + unprivOK bool + invalid bool + rootAlso bool + }{ + {name: "no upload", url: "", unprivOK: true}, + {name: "default service", url: types.DefaultBundleURL, unprivOK: true}, + {name: "default service, other path", url: "https://upload.debug.netbird.io/other", unprivOK: true}, + {name: "loopback exfiltration endpoint", url: "https://127.0.0.1:8080/upload-url", rootAlso: true}, + {name: "custom upload service", url: "https://attacker.example/upload-url", rootAlso: true}, + {name: "plaintext default host", url: "http://upload.debug.netbird.io/upload-url", invalid: true}, + {name: "plaintext custom host", url: "http://attacker.example/upload-url", invalid: true}, + {name: "unsupported scheme", url: "file:///etc/shadow", invalid: true}, + // insecure relaxes transport security; privileged only, whatever the host. + {name: "insecure http custom", url: "http://selfhosted.local/upload-url", insecure: true, rootAlso: true}, + {name: "insecure https custom", url: "https://selfhosted.local/upload-url", insecure: true, rootAlso: true}, + {name: "insecure default host", url: types.DefaultBundleURL, insecure: true, rootAlso: true}, + // --insecure must not widen the URL to non-http(s) schemes or a hostless URL. + {name: "insecure file scheme", url: "file:///etc/shadow", insecure: true, invalid: true}, + {name: "insecure hostless", url: "https:///upload-url", insecure: true, invalid: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := requirePrivilegeForUploadURL(userCtx(), tc.url, tc.insecure) + + switch { + case tc.invalid: + if gstatus.Code(err) != codes.InvalidArgument { + t.Fatalf("code = %v, want InvalidArgument", gstatus.Code(err)) + } + return + case tc.unprivOK: + assertAllowed(t, err) + return + default: + assertDenied(t, err) + } + + if tc.rootAlso { + assertAllowed(t, requirePrivilegeForUploadURL(rootCtx(), tc.url, tc.insecure)) + } + }) + } +} diff --git a/client/server/jwt_cache.go b/client/server/jwt_cache.go index 21e170517..73cec046d 100644 --- a/client/server/jwt_cache.go +++ b/client/server/jwt_cache.go @@ -6,11 +6,21 @@ import ( "github.com/awnumar/memguard" log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/ipcauth" ) type jwtCache struct { - mu sync.RWMutex - enclave *memguard.Enclave + mu sync.RWMutex + enclave *memguard.Enclave + owner *ipcauth.Identity + + // generation counts the invalidations. A caller that starts an + // authentication takes the generation first and hands it back to store, so + // a token obtained under a session that ended while the IdP was being + // polled cannot land in the cache the new session is using. + generation uint64 + expiresAt time.Time timer *time.Timer maxTokenSize int @@ -22,10 +32,23 @@ func newJWTCache() *jwtCache { } } -func (c *jwtCache) store(token string, maxAge time.Duration) { +func (c *jwtCache) currentGeneration() uint64 { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.generation +} + +// store keeps the token only while generation is still the current one, and +// reports whether it did. See the generation field. +func (c *jwtCache) store(token string, owner ipcauth.Identity, maxAge time.Duration, generation uint64) bool { c.mu.Lock() defer c.mu.Unlock() + if c.generation != generation { + return false + } + c.cleanup() if c.timer != nil { @@ -35,6 +58,7 @@ func (c *jwtCache) store(token string, maxAge time.Duration) { tokenBytes := []byte(token) c.enclave = memguard.NewEnclave(tokenBytes) + c.owner = &owner c.expiresAt = time.Now().Add(maxAge) var timer *time.Timer @@ -49,9 +73,12 @@ func (c *jwtCache) store(token string, maxAge time.Duration) { log.Debugf("JWT token cache expired after %v, securely wiped from memory", maxAge) }) c.timer = timer + + return true } -func (c *jwtCache) get() (string, bool) { +// get returns the cached token to the identity that stored it. +func (c *jwtCache) get(caller ipcauth.Identity) (string, bool) { c.mu.RLock() defer c.mu.RUnlock() @@ -59,6 +86,11 @@ func (c *jwtCache) get() (string, bool) { return "", false } + if c.owner == nil || !c.owner.SameUser(caller) { + log.Warnf("refusing the cached SSH JWT: caller %s is not the identity that obtained it", caller) + return "", false + } + buffer, err := c.enclave.Open() if err != nil { log.Debugf("Failed to open JWT token enclave: %v", err) @@ -70,10 +102,23 @@ func (c *jwtCache) get() (string, bool) { return token, true } +func (c *jwtCache) clear() { + c.mu.Lock() + defer c.mu.Unlock() + + if c.timer != nil { + c.timer.Stop() + c.timer = nil + } + c.cleanup() + c.generation++ +} + // cleanup destroys the secure enclave, must be called with lock held func (c *jwtCache) cleanup() { if c.enclave != nil { c.enclave = nil } + c.owner = nil c.expiresAt = time.Time{} } diff --git a/client/server/jwt_cache_test.go b/client/server/jwt_cache_test.go new file mode 100644 index 000000000..11d208ade --- /dev/null +++ b/client/server/jwt_cache_test.go @@ -0,0 +1,176 @@ +package server + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/ipcauth" +) + +const testTTL = time.Minute + +func unixCaller(uid uint32) ipcauth.Identity { + return ipcauth.Identity{UID: uid, GID: uid} +} + +func windowsCaller(sid string) ipcauth.Identity { + return ipcauth.Identity{SID: sid} +} + +func TestJWTCache_ServesTheOwner(t *testing.T) { + c := newJWTCache() + owner := unixCaller(1000) + c.store("token-for-1000", owner, testTTL, c.currentGeneration()) + + got, found := c.get(owner) + + require.True(t, found, "the identity that stored the token must get it back") + assert.Equal(t, "token-for-1000", got) +} + +// The disclosure this cache guards against: one local account collecting the +// SSH JWT another account's authentication put in the daemon-wide cache. +func TestJWTCache_RefusesAnotherLocalUser(t *testing.T) { + tests := []struct { + name string + owner ipcauth.Identity + caller ipcauth.Identity + }{ + {"different uid", unixCaller(1000), unixCaller(65534)}, + {"root is not the owner either", unixCaller(1000), unixCaller(0)}, + {"different sid", windowsCaller("S-1-5-21-1-2-3-1001"), windowsCaller("S-1-5-21-1-2-3-1002")}, + {"windows caller against a unix owner", unixCaller(0), windowsCaller("S-1-5-18")}, + {"unix caller against a windows owner", windowsCaller("S-1-5-18"), unixCaller(0)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := newJWTCache() + c.store("victim-token", tt.owner, testTTL, c.currentGeneration()) + + got, found := c.get(tt.caller) + + assert.False(t, found, "a caller that is not the owner must get a miss") + assert.Empty(t, got) + }) + } +} + +func TestJWTCache_EmptyCacheMatchesNobody(t *testing.T) { + c := newJWTCache() + + got, found := c.get(unixCaller(0)) + + assert.False(t, found) + assert.Empty(t, got) +} + +// An entry with no recorded owner must match nobody, root included: an +// unidentified caller arrives as the zero Identity, which carries uid 0. This +// pins the nil-owner guard rather than the comparison, so it sets up an entry +// that exists and then drops its owner. +func TestJWTCache_UnownedEntryMatchesNobody(t *testing.T) { + c := newJWTCache() + c.store("token", unixCaller(1000), testTTL, c.currentGeneration()) + c.owner = nil + + got, found := c.get(unixCaller(0)) + + assert.False(t, found) + assert.Empty(t, got) +} + +// The same user calling once elevated and once not is still the same user, so +// hiding their own token from them would be wrong. +func TestJWTCache_ElevationDoesNotChangeTheOwner(t *testing.T) { + c := newJWTCache() + sid := "S-1-5-21-1-2-3-1001" + owner := windowsCaller(sid) + owner.Elevated = true + c.store("token", owner, testTTL, c.currentGeneration()) + + got, found := c.get(windowsCaller(sid)) + + require.True(t, found) + assert.Equal(t, "token", got) +} + +func TestJWTCache_Expiry(t *testing.T) { + c := newJWTCache() + owner := unixCaller(1000) + c.store("token", owner, testTTL, c.currentGeneration()) + c.expiresAt = time.Now().Add(-time.Second) + + _, found := c.get(owner) + + assert.False(t, found) +} + +// Logout and SwitchProfile call clear — Down deliberately does not: the NetBird +// session the token speaks for is over, so not even its owner may have it back. +func TestJWTCache_ClearDropsTheEntry(t *testing.T) { + c := newJWTCache() + owner := unixCaller(1000) + c.store("token", owner, testTTL, c.currentGeneration()) + + c.clear() + + _, found := c.get(owner) + assert.False(t, found) + assert.Nil(t, c.owner, "clear must forget the owner too") + assert.Nil(t, c.timer, "clear must stop the expiry timer") +} + +// WaitJWTToken polls the IdP unlocked, so a logout or a profile switch can +// clear the cache while a flow is still in the air. The token that flow returns +// belongs to the session that ended, so it must not land in the cache the new +// session is using. +func TestJWTCache_StoreFromAnEndedSessionIsDropped(t *testing.T) { + c := newJWTCache() + owner := unixCaller(1000) + + // The generation a caller takes when its authentication starts. + generation := c.currentGeneration() + + c.clear() // logout or profile switch, while the IdP is still being polled + + stored := c.store("stale-token", owner, testTTL, generation) + + assert.False(t, stored, "a token from an ended session must not be cached") + _, found := c.get(owner) + assert.False(t, found, "the cache must stay empty after the session ended") +} + +// The same caller must still be able to store once it re-reads the generation, so +// the guard does not wedge the cache after any invalidation. +func TestJWTCache_StoreWorksAgainAfterClear(t *testing.T) { + c := newJWTCache() + owner := unixCaller(1000) + + c.clear() + + require.True(t, c.store("token", owner, testTTL, c.currentGeneration())) + + got, found := c.get(owner) + require.True(t, found) + assert.Equal(t, "token", got) +} + +func TestJWTCache_StoreReplacesThePreviousOwner(t *testing.T) { + c := newJWTCache() + first := unixCaller(1000) + second := unixCaller(1001) + + c.store("first-token", first, testTTL, c.currentGeneration()) + c.store("second-token", second, testTTL, c.currentGeneration()) + + _, found := c.get(first) + assert.False(t, found, "the previous owner must not reach the new token") + + got, found := c.get(second) + require.True(t, found) + assert.Equal(t, "second-token", got) +} diff --git a/client/server/lock_order_test.go b/client/server/lock_order_test.go new file mode 100644 index 000000000..457e3db34 --- /dev/null +++ b/client/server/lock_order_test.go @@ -0,0 +1,51 @@ +package server + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/proto" +) + +// The daemon takes guardedConfigMu before s.mutex. authorizeAndPrepareLogin +// takes s.mutex while holding guardedConfigMu, so a SetConfig that grabbed +// s.mutex first and then waited for guardedConfigMu would deadlock the daemon +// against a concurrent login: two unprivileged IPC calls are enough. +// +// The held guardedConfigMu below stands in for that login. While SetConfig waits +// for it, s.mutex must stay free, otherwise the login waiting for s.mutex could +// never release guardedConfigMu. +func TestSetConfig_TakesGuardedConfigMuBeforeServerMutex(t *testing.T) { + s, ctx, profName, username, _ := setupServerWithProfile(t) + + s.guardedConfigMu.Lock() + + done := make(chan error, 1) + go func() { + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + }) + done <- err + }() + + require.Never(t, func() bool { + if !s.mutex.TryLock() { + return true + } + s.mutex.Unlock() + return false + }, 500*time.Millisecond, 10*time.Millisecond, + "SetConfig held s.mutex while waiting for guardedConfigMu, which deadlocks against a concurrent login") + + s.guardedConfigMu.Unlock() + + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("SetConfig did not finish after guardedConfigMu was released") + } +} diff --git a/client/server/login_gate_test.go b/client/server/login_gate_test.go new file mode 100644 index 000000000..de62a8180 --- /dev/null +++ b/client/server/login_gate_test.go @@ -0,0 +1,127 @@ +package server + +import ( + "context" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/proto" +) + +// A refused login must not leave the profile switched. Login can both switch +// profiles and carry the guarded config fields, so the gate has to run before the +// switch: otherwise a caller whose change is refused still gets the side effect of +// activating whichever profile the request named. +func TestLogin_RefusedChangeLeavesTheProfileAlone(t *testing.T) { + s, _, activeProfile, username, _ := setupServerWithProfile(t) + + // Login reads process state off the daemon's root context. + s.rootCtx = internal.CtxInitState(context.Background()) + + // A second profile that runs the SSH server, which is what makes repointing + // its management binding a privileged change. + target := "ssh-enabled" + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, target+".json"), + ManagementURL: "https://api.netbird.io:443", + ServerSSHAllowed: boolPtr(true), + }) + require.NoError(t, err) + + _, err = s.Login(userCtx(), &proto.LoginRequest{ + ProfileName: &target, + Username: &username, + ManagementUrl: "https://mgmt.attacker.example:443", + }) + require.Error(t, err, "an unprivileged caller must not move the management URL of an SSH-enabled profile") + require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want a privilege refusal, got %v", err) + + active, err := s.profileManager.GetActiveProfileState() + require.NoError(t, err) + require.Equal(t, profilemanager.ID(activeProfile), active.ID, + "the refused login switched the active profile anyway") +} + +// A caller whose change becomes privileged only after its first check must be +// refused without having cancelled a login or switched profiles: the first check is +// unsynchronized, so the SSH server can be enabled by a concurrent privileged +// request in between, and the authoritative check happens before any side effect. +func TestLogin_ChangeThatBecomesPrivilegedMidRequestHasNoSideEffects(t *testing.T) { + s, _, activeProfile, username, _ := setupServerWithProfile(t) + s.rootCtx = internal.CtxInitState(context.Background()) + + // The target profile has SSH off, so the first check lets the request through. + target := "ssh-later" + targetPath := filepath.Join(profilemanager.DefaultConfigPathDir, target+".json") + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: targetPath, + ManagementURL: "https://api.netbird.io:443", + ServerSSHAllowed: boolPtr(false), + }) + require.NoError(t, err) + + cancelled := false + s.actCancel = func() { cancelled = true } + + // Stand in for a privileged SetConfig that enables the SSH server between the + // two checks, which is the interleaving the lock has to make safe. + afterLoginPreCheck = func() { + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: targetPath, + ServerSSHAllowed: boolPtr(true), + }) + require.NoError(t, err) + } + t.Cleanup(func() { afterLoginPreCheck = nil }) + + _, err = s.Login(userCtx(), &proto.LoginRequest{ + ProfileName: &target, + Username: &username, + ManagementUrl: "https://mgmt.attacker.example:443", + }) + require.Error(t, err) + require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want a privilege refusal, got %v", err) + require.False(t, cancelled, "the refused login cancelled the login already in progress") + + active, err := s.profileManager.GetActiveProfileState() + require.NoError(t, err) + require.Equal(t, profilemanager.ID(activeProfile), active.ID, "the refused login switched the active profile anyway") + + stored, err := profilemanager.ReadConfig(targetPath) + require.NoError(t, err) + require.Equal(t, "https://api.netbird.io:443", stored.ManagementURL.String(), "the refused login moved the management URL") +} + +// Login cancels whatever login is already in progress before starting its own. A +// refused caller must not get that far, otherwise anyone able to reach the socket +// can abort someone else's login by sending a request that is denied. +func TestLogin_RefusedChangeLeavesAnInProgressLoginAlone(t *testing.T) { + s, _, _, username, _ := setupServerWithProfile(t) + s.rootCtx = internal.CtxInitState(context.Background()) + + target := "ssh-enabled" + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, target+".json"), + ManagementURL: "https://api.netbird.io:443", + ServerSSHAllowed: boolPtr(true), + }) + require.NoError(t, err) + + cancelled := false + s.actCancel = func() { cancelled = true } + + _, err = s.Login(userCtx(), &proto.LoginRequest{ + ProfileName: &target, + Username: &username, + ManagementUrl: "https://mgmt.attacker.example:443", + }) + require.Error(t, err) + require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want a privilege refusal, got %v", err) + require.False(t, cancelled, "the refused login cancelled the login already in progress") +} diff --git a/client/server/login_outcome_test.go b/client/server/login_outcome_test.go new file mode 100644 index 000000000..7ebf04f92 --- /dev/null +++ b/client/server/login_outcome_test.go @@ -0,0 +1,110 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/client/proto" +) + +// A login that never reached Management is not a decision about the peer's +// credentials, so it must come back as a retryable error rather than an SSO +// prompt: the user cannot finish a browser login while Management is down, and +// the CLI's own backoff resolves the outage on its own once the daemon reports +// the failure. Reproduces `netbird down; netbird up` printing a device-code URL +// because Management happened to be restarting when the daemon dialed it. +func TestLogin_ManagementUnreachableIsReturnedInsteadOfDemandingSSO(t *testing.T) { + s, _, _, username, _ := setupServerWithProfile(t) + s.rootCtx = internal.CtxInitState(context.Background()) + + unreachable := errors.New("create connection: dial context: context deadline exceeded") + attempts := 0 + s.isLoginRequiredFn = func(context.Context) (bool, error) { + attempts++ + return false, unreachable + } + + resp, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username}) + require.Error(t, err) + require.ErrorIs(t, err, unreachable, "the transport failure was replaced by something else") + require.Nil(t, resp, "a failed login must not answer with a login response") + require.Equal(t, 1, attempts) + require.Nil(t, s.oauthAuthFlow.flow, "the daemon started an SSO flow for a peer whose login was never decided") + + status, err := internal.CtxGetState(s.rootCtx).Status() + require.NoError(t, err) + require.Equal(t, internal.StatusLoginFailed, status, + "a peer that could not reach Management is not waiting on a login") +} + +// The counterpart: Management refusing the peer's credentials is a decision, and +// the SSO flow still has to start for it. The profile carries an unusable +// private key so the flow setup fails immediately instead of dialing, which is +// enough to show the branch was entered — the refusal itself is never what comes +// back out. +func TestLogin_AuthRefusalStartsSSOFlow(t *testing.T) { + s, _, _, username, cfgPath := setupServerWithProfile(t) + s.rootCtx = internal.CtxInitState(context.Background()) + breakProfilePrivateKey(t, cfgPath) + + s.isLoginRequiredFn = func(context.Context) (bool, error) { + return true, nil + } + + _, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username}) + require.Error(t, err) + + status, stateErr := internal.CtxGetState(s.rootCtx).Status() + require.NoError(t, stateErr) + require.Equal(t, internal.StatusLoginFailed, status, + "the SSO flow setup was never reached with the broken key") +} + +func TestLogin_SetupKeyStillRunsWhenPeerNeedsLogin(t *testing.T) { + s, _, _, username, _ := setupServerWithProfile(t) + s.rootCtx = internal.CtxInitState(context.Background()) + + s.isLoginRequiredFn = func(context.Context) (bool, error) { + return true, nil + } + + var keysTried []string + s.loginAttemptFn = func(_ context.Context, setupKey, _ string) (internal.StatusType, error) { + keysTried = append(keysTried, setupKey) + return "", nil + } + + setupKey := "A2C8E32F-AEB2-4B45-8FD3-8A0C1B2D3E4F" + resp, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username, SetupKey: setupKey}) + require.NoError(t, err, "the probe's outcome leaked out as the login result") + require.NotNil(t, resp) + require.Equal(t, []string{setupKey}, keysTried, "the setup key never reached the login attempt") + require.Nil(t, s.oauthAuthFlow.flow, "a setup-key login started an SSO flow") + + status, err := internal.CtxGetState(s.rootCtx).Status() + require.NoError(t, err) + require.Equal(t, internal.StatusIdle, status) +} + +// breakProfilePrivateKey replaces the profile's private key with an unparseable +// one, which makes any attempt to build a Management client fail on the spot. +func breakProfilePrivateKey(t *testing.T, cfgPath string) { + t.Helper() + + raw, err := os.ReadFile(cfgPath) + require.NoError(t, err) + + var cfg map[string]any + require.NoError(t, json.Unmarshal(raw, &cfg)) + cfg["PrivateKey"] = "not-a-key" + + patched, err := json.Marshal(cfg) + require.NoError(t, err) + require.NoError(t, os.WriteFile(cfgPath, patched, 0o600)) +} diff --git a/client/server/logout_gate_test.go b/client/server/logout_gate_test.go new file mode 100644 index 000000000..2d84d1b6a --- /dev/null +++ b/client/server/logout_gate_test.go @@ -0,0 +1,200 @@ +package server + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/proto" +) + +// unreachableManagementURL keeps a test that is expected to stop at a gate from +// reaching the network if the gate ever regresses: the profiles a logout must +// not touch point here, so a leak fails fast instead of contacting a real +// management server. +const unreachableManagementURL = "https://127.0.0.1:9" + +// enableSSHOnProfile rewrites the profile config at cfgPath with the SSH server +// enabled. Deregistering an SSH-enabled profile is a privileged change, so an +// unprivileged caller is refused by requirePrivilegeForDeregistration before any +// management connection is attempted, which is what keeps these tests offline. +func enableSSHOnProfile(t *testing.T, cfgPath string) { + t.Helper() + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: cfgPath, + ManagementURL: "https://api.netbird.io:443", + ServerSSHAllowed: boolPtr(true), + }) + require.NoError(t, err) +} + +// Logging out of the profile the daemon is already running is a deregistration, +// not profile management, so the profiles-disabled kill switch must not block +// it. The desktop UI always addresses logout by profile (both the profile menu +// and the session-expiration dialog), so gating it left users with +// disableProfiles enforced unable to log out at all. +func TestLogout_ActiveProfileAllowedWhenProfilesDisabled(t *testing.T) { + s, _, activeProfile, username, cfgPath := setupServerWithProfile(t) + s.rootCtx = internal.CtxInitState(context.Background()) + enableSSHOnProfile(t, cfgPath) + + s.profilesDisabled = true + + _, err := s.Logout(userCtx(), &proto.LogoutRequest{ + ProfileName: &activeProfile, + Username: &username, + }) + + require.Error(t, err, "the SSH privilege gate is expected to refuse this unprivileged caller") + require.Equal(t, codes.PermissionDenied, gstatus.Code(err), + "logout of the active profile must reach the deregistration path, not be refused as profile management: %v", err) + require.NotContains(t, gstatus.Convert(err).Message(), errProfilesDisabled) +} + +// A profile-addressed logout that targets some *other* profile does manage +// profiles, so it stays gated: with profiles disabled the daemon must not +// deregister a peer the user is not currently running. +func TestLogout_OtherProfileStaysGatedWhenProfilesDisabled(t *testing.T) { + s, _, _, username, _ := setupServerWithProfile(t) + s.rootCtx = internal.CtxInitState(context.Background()) + + other := "other-profile" + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, other+".json"), + ManagementURL: unreachableManagementURL, + }) + require.NoError(t, err) + + s.profilesDisabled = true + + _, err = s.Logout(userCtx(), &proto.LogoutRequest{ + ProfileName: &other, + Username: &username, + }) + + require.Error(t, err) + require.Equal(t, codes.Unavailable, gstatus.Code(err), "want the profiles-disabled refusal, got %v", err) + require.Contains(t, gstatus.Convert(err).Message(), errProfilesDisabled) +} + +// A legacy profile ID is a display name, so two users can hold the same ID in +// their own profile directories. Matching on the ID alone would let one user's +// logout pass the gate against the other user's active profile, so the username +// is part of the comparison. +func TestLogout_ForeignUserProfileStaysGatedWhenProfilesDisabled(t *testing.T) { + s, _, _, username, _ := setupServerWithProfile(t) + s.rootCtx = internal.CtxInitState(context.Background()) + + // A legacy-style profile whose ID is its filename stem, and an active state + // claiming that same ID for a different user. + shared := "shared-legacy-name" + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, shared+".json"), + ManagementURL: unreachableManagementURL, + }) + require.NoError(t, err) + require.NoError(t, s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{ + ID: profilemanager.ID(shared), + Username: "someone-else", + })) + + s.profilesDisabled = true + + _, err = s.Logout(userCtx(), &proto.LogoutRequest{ + ProfileName: &shared, + Username: &username, + }) + + require.Error(t, err) + require.Equal(t, codes.Unavailable, gstatus.Code(err), + "another user's profile must not pass the gate on an ID match alone: %v", err) +} + +// Deregistering a namesake profile must not go out with the running config. +// logoutFromProfile reuses the connected client's config when the target is the +// active profile, and on an ID-only match a shared legacy ID made it reuse it +// for another user's profile, deregistering the active peer instead. +func TestLogout_ForeignUserProfileDoesNotUseTheRunningConfig(t *testing.T) { + s, _, _, username, cfgPath := setupServerWithProfile(t) + s.rootCtx = internal.CtxInitState(context.Background()) + + // The running config has the SSH server enabled, so reusing it would be + // refused with PermissionDenied. The namesake profile does not, so the + // correct path gets as far as dialing its own unreachable management URL. + enableSSHOnProfile(t, cfgPath) + running, err := profilemanager.GetConfig(cfgPath) + require.NoError(t, err) + s.config = running + s.connectClient = newDummyConnectClient(context.Background()) + + shared := "shared-legacy-name" + _, err = profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, shared+".json"), + ManagementURL: unreachableManagementURL, + }) + require.NoError(t, err) + require.NoError(t, s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{ + ID: profilemanager.ID(shared), + Username: "someone-else", + })) + + // Bounded so the deregistration the fixed path attempts fails on the dial + // rather than sitting in gRPC backoff for the whole test timeout. + ctx, cancel := context.WithTimeout(userCtx(), 2*time.Second) + t.Cleanup(cancel) + + _, err = s.Logout(ctx, &proto.LogoutRequest{ + ProfileName: &shared, + Username: &username, + }) + + require.Error(t, err) + require.NotEqual(t, codes.PermissionDenied, gstatus.Code(err), + "the namesake profile was deregistered with the running config: %v", err) +} + +// The connection teardown follows the profile that is active when the logout +// completes, not the one seen before it started: Login switches profiles under +// guardedConfigMu, which the logout path does not hold, so a login that landed +// meanwhile must keep its connection. +func TestCleanupAfterProfileLogout_FollowsTheCurrentActiveProfile(t *testing.T) { + s, _, activeProfile, username, _ := setupServerWithProfile(t) + s.rootCtx = internal.CtxInitState(context.Background()) + + state := internal.CtxGetState(s.rootCtx) + + s.cleanupAfterProfileLogout("some-other-profile", username) + status, err := state.Status() + require.NoError(t, err) + require.NotEqual(t, internal.StatusNeedsLogin, status, + "logging out of a profile that is not active must not ask for a new login") + + s.cleanupAfterProfileLogout(profilemanager.ID(activeProfile), username) + status, err = state.Status() + require.NoError(t, err) + require.Equal(t, internal.StatusNeedsLogin, status, + "logging out of the active profile must ask for a new login") +} + +// With profiles enabled the gate is out of the way on both surfaces; the active +// profile still reaches the deregistration path. +func TestLogout_ActiveProfileAllowedWhenProfilesEnabled(t *testing.T) { + s, _, activeProfile, username, cfgPath := setupServerWithProfile(t) + s.rootCtx = internal.CtxInitState(context.Background()) + enableSSHOnProfile(t, cfgPath) + + _, err := s.Logout(userCtx(), &proto.LogoutRequest{ + ProfileName: &activeProfile, + Username: &username, + }) + + require.Error(t, err, "the SSH privilege gate is expected to refuse this unprivileged caller") + require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want the privilege refusal, got %v", err) +} diff --git a/client/server/mdm.go b/client/server/mdm.go index 9836c6bea..7a47b2a57 100644 --- a/client/server/mdm.go +++ b/client/server/mdm.go @@ -3,7 +3,6 @@ package server import ( "context" "fmt" - "net/url" "time" log "github.com/sirupsen/logrus" @@ -14,28 +13,6 @@ import ( "github.com/netbirdio/netbird/client/proto" ) -// preSharedKeyRedactedSentinel is the value GetConfig returns in place -// of an actual PSK, so a UI that round-trips the field back to the -// daemon (via SetConfig / Login) can be distinguished from a deliberate -// override. Any incoming PSK that equals this sentinel is treated as -// a no-op echo, never as a conflict with the policy. -const preSharedKeyRedactedSentinel = "**********" - -// loadMDMPolicy is the indirection used by server handlers to read the -// active MDM policy. Tests override this to inject a fake policy. -var loadMDMPolicy = mdm.LoadPolicy - -// conflictCheck is a value-aware comparison between a single field in -// the incoming request and the corresponding MDM-enforced value. It -// runs only when the field was actually set in the request (presence -// already filtered upstream); ok=true reports the policy value, ok=false -// means the policy is silent on the key — both are treated as conflicts -// to be safe (an MDM key declared as managed must hold a value). -type conflictCheck struct { - key string - check func(*mdm.Policy) (match bool) -} - // onMDMPolicyChange is invoked by the MDM reload ticker every time the // OS-native managed-config store reports a diff vs the last observation. // @@ -168,108 +145,6 @@ func (s *Server) restartEngineForMDMLocked() error { return nil } -// conflictBool builds a conflictCheck for a boolean MDM key. If p is nil -// the field is treated as matching (no override requested); otherwise the -// check returns true only when the policy contains the key and its -// boolean value equals *p. -func conflictBool(key string, p *bool) conflictCheck { - return conflictCheck{ - key: key, - check: func(pol *mdm.Policy) bool { - if p == nil { - return true // absent → match by definition - } - want, ok := pol.GetBool(key) - return ok && want == *p - }, - } -} - -func canonicalURL(s string) string { - u, err := url.ParseRequestURI(s) - if err != nil { - return s - } - if u.Port() == "" { - switch u.Scheme { - case "https": - u.Host += ":443" - case "http": - u.Host += ":80" - } - } - return u.String() -} - -// conflictURL is conflictString for URL-typed keys: both sides are -// normalized via canonicalURL before comparison. -func conflictURL(key, got string) conflictCheck { - return conflictCheck{ - key: key, - check: func(pol *mdm.Policy) bool { - if got == "" { - return true - } - want, ok := pol.GetString(key) - return ok && canonicalURL(want) == canonicalURL(got) - }, - } -} - -// conflictString builds a conflictCheck for a string MDM key. An empty -// `got` is treated as "field not set" (no override requested); otherwise -// the check returns true only when the policy contains the key and its -// value equals got. -func conflictString(key, got string) conflictCheck { - return conflictCheck{ - key: key, - check: func(pol *mdm.Policy) bool { - if got == "" { - return true - } - want, ok := pol.GetString(key) - return ok && want == got - }, - } -} - -// conflictInt64 builds a conflictCheck for an integer MDM key. If p is -// nil the field is treated as matching; otherwise the check returns -// true only when the policy contains the key and its int value equals *p. -func conflictInt64(key string, p *int64) conflictCheck { - return conflictCheck{ - key: key, - check: func(pol *mdm.Policy) bool { - if p == nil { - return true - } - want, ok := pol.GetInt(key) - return ok && want == *p - }, - } -} - -// resolveConflicts walks the per-field checks against the active MDM -// policy and returns the names of keys whose requested value diverges -// from the policy-enforced value. Keys not present in the policy are -// skipped silently (the gate fires only for keys the admin has -// actually pushed). Returns nil for an empty policy. -func resolveConflicts(policy *mdm.Policy, checks []conflictCheck) []string { - if policy.IsEmpty() { - return nil - } - var conflicts []string - for _, c := range checks { - if !policy.HasKey(c.key) { - continue - } - if !c.check(policy) { - conflicts = append(conflicts, c.key) - } - } - return conflicts -} - // mdmManagedFieldConflicts returns the names of MDM-managed keys whose // requested value in the SetConfigRequest differs from the MDM-enforced // value. A field set to the same value the policy already enforces is @@ -283,24 +158,25 @@ func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) [ return nil } - // PSK round-trip echo: collapse the sentinel to empty so the - // shared check treats it as "field not set". - pskGot := "" - if msg.OptionalPreSharedKey != nil && *msg.OptionalPreSharedKey != preSharedKeyRedactedSentinel { - pskGot = *msg.OptionalPreSharedKey + pskGot := msg.OptionalPreSharedKey + if pskGot != nil && *pskGot == mdm.PreSharedKeyRedactedSentinel { + pskGot = nil } - return resolveConflicts(policy, []conflictCheck{ - conflictURL(mdm.KeyManagementURL, msg.ManagementUrl), - conflictString(mdm.KeyPreSharedKey, pskGot), - conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled), - conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), - conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect), - conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed), - conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes), - conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), - conflictBool(mdm.KeyBlockInbound, msg.BlockInbound), - conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort), + return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{ + mdm.ConflictURL(mdm.KeyManagementURL, msg.ManagementUrl), + mdm.ConflictStringPtr(mdm.KeyPreSharedKey, pskGot), + mdm.ConflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled), + mdm.ConflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), + mdm.ConflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect), + mdm.ConflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed), + mdm.ConflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed), + mdm.ConflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes), + mdm.ConflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), + mdm.ConflictBool(mdm.KeyBlockInbound, msg.BlockInbound), + mdm.ConflictInt64(mdm.KeyWireguardPort, msg.WireguardPort), + mdm.ConflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics), + mdm.ConflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress), }) } @@ -332,6 +208,7 @@ func setConfigRequestHasConfigOverrides(msg *proto.SetConfigRequest) bool { msg.Mtu != nil || msg.DisableAutoConnect != nil || msg.ServerSSHAllowed != nil || + msg.RemoteJobsAllowed != nil || msg.NetworkMonitor != nil || msg.DisableClientRoutes != nil || msg.DisableServerRoutes != nil || @@ -346,7 +223,9 @@ func setConfigRequestHasConfigOverrides(msg *proto.SetConfigRequest) bool { msg.EnableSSHLocalPortForwarding != nil || msg.EnableSSHRemotePortForwarding != nil || msg.DisableSSHAuth != nil || - msg.SshJWTCacheTTL != nil + msg.SshJWTCacheTTL != nil || + msg.EnableLocalMetrics != nil || + msg.LocalMetricsAddress != nil } // loginRequestHasConfigOverrides reports whether the LoginRequest @@ -370,6 +249,7 @@ func loginRequestHasConfigOverrides(msg *proto.LoginRequest) bool { msg.WireguardPort != nil || msg.DisableAutoConnect != nil || msg.ServerSSHAllowed != nil || + msg.RemoteJobsAllowed != nil || msg.RosenpassPermissive != nil || len(msg.ExtraIFaceBlacklist) > 0 || msg.NetworkMonitor != nil || @@ -381,7 +261,9 @@ func loginRequestHasConfigOverrides(msg *proto.LoginRequest) bool { msg.BlockLanAccess != nil || msg.DisableNotifications != nil || len(msg.DnsLabels) > 0 || msg.CleanDNSLabels || - msg.BlockInbound != nil + msg.BlockInbound != nil || + msg.EnableLocalMetrics != nil || + msg.LocalMetricsAddress != nil } // loginRequestMDMConflicts mirrors mdmManagedFieldConflicts but for the @@ -397,31 +279,28 @@ func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []str return nil } - // Collapse the two PSK fields + the redaction sentinel down to a - // single "got" string the shared check can compare against the - // policy: OptionalPreSharedKey wins if set; PreSharedKey (deprecated) - // is the fallback; sentinel echo is treated as "field not set". - pskGot := "" - if msg.OptionalPreSharedKey != nil { - pskGot = *msg.OptionalPreSharedKey - } else if msg.PreSharedKey != "" { //nolint:staticcheck // SA1019: legacy proto field still accepted by Login - pskGot = msg.PreSharedKey //nolint:staticcheck // SA1019 + pskGot := msg.OptionalPreSharedKey + if pskGot == nil && msg.PreSharedKey != "" { //nolint:staticcheck // SA1019: legacy proto field still accepted by Login + pskGot = &msg.PreSharedKey //nolint:staticcheck // SA1019 } - if pskGot == preSharedKeyRedactedSentinel { - pskGot = "" + if pskGot != nil && *pskGot == mdm.PreSharedKeyRedactedSentinel { + pskGot = nil } - return resolveConflicts(policy, []conflictCheck{ - conflictURL(mdm.KeyManagementURL, msg.ManagementUrl), - conflictString(mdm.KeyPreSharedKey, pskGot), - conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled), - conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), - conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect), - conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed), - conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes), - conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), - conflictBool(mdm.KeyBlockInbound, msg.BlockInbound), - conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort), + return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{ + mdm.ConflictURL(mdm.KeyManagementURL, msg.ManagementUrl), + mdm.ConflictStringPtr(mdm.KeyPreSharedKey, pskGot), + mdm.ConflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled), + mdm.ConflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), + mdm.ConflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect), + mdm.ConflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed), + mdm.ConflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed), + mdm.ConflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes), + mdm.ConflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), + mdm.ConflictBool(mdm.KeyBlockInbound, msg.BlockInbound), + mdm.ConflictInt64(mdm.KeyWireguardPort, msg.WireguardPort), + mdm.ConflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics), + mdm.ConflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress), }) } diff --git a/client/server/network.go b/client/server/network.go index c38715256..69eaabf8a 100644 --- a/client/server/network.go +++ b/client/server/network.go @@ -8,7 +8,6 @@ import ( "sort" "strings" - "golang.org/x/exp/maps" "google.golang.org/grpc/codes" gstatus "google.golang.org/grpc/status" @@ -161,30 +160,11 @@ func (s *Server) SelectNetworks(_ context.Context, req *proto.SelectNetworksRequ return nil, fmt.Errorf("no route manager") } - routeSelector := routeManager.GetRouteSelector() if req.GetAll() { - routeSelector.SelectAllRoutes() - } else { - routes := toNetIDs(req.GetNetworkIDs()) - routesMap := routeManager.GetClientRoutesWithNetID() - routes = route.ExpandV6ExitPairs(routes, routesMap) - netIdRoutes := maps.Keys(routesMap) - if err := routeSelector.SelectRoutes(routes, req.GetAppend(), netIdRoutes); err != nil { - return nil, fmt.Errorf("select routes: %w", err) - } - - // Exit nodes are mutually exclusive: if this selection activates an - // exit node, deselect every other available exit node so two can't be - // selected at once. Non-exit route selections are left untouched. - if requestActivatesExitNode(routes, routesMap) { - if others := otherExitNodeIDs(routesMap, routes); len(others) > 0 { - if err := routeSelector.DeselectRoutes(others, netIdRoutes); err != nil { - return nil, fmt.Errorf("deselect sibling exit nodes: %w", err) - } - } - } + routeManager.SelectAllRoutes() + } else if err := routeManager.SelectRoutes(toNetIDs(req.GetNetworkIDs()), req.GetAppend()); err != nil { + return nil, err } - routeManager.TriggerSelection(routeManager.GetClientRoutes()) s.statusRecorder.PublishEvent( proto.SystemEvent_INFO, @@ -224,19 +204,11 @@ func (s *Server) DeselectNetworks(_ context.Context, req *proto.SelectNetworksRe return nil, fmt.Errorf("no route manager") } - routeSelector := routeManager.GetRouteSelector() if req.GetAll() { - routeSelector.DeselectAllRoutes() - } else { - routes := toNetIDs(req.GetNetworkIDs()) - routesMap := routeManager.GetClientRoutesWithNetID() - routes = route.ExpandV6ExitPairs(routes, routesMap) - netIdRoutes := maps.Keys(routesMap) - if err := routeSelector.DeselectRoutes(routes, netIdRoutes); err != nil { - return nil, fmt.Errorf("deselect routes: %w", err) - } + routeManager.DeselectAllRoutes() + } else if err := routeManager.DeselectRoutes(toNetIDs(req.GetNetworkIDs())); err != nil { + return nil, err } - routeManager.TriggerSelection(routeManager.GetClientRoutes()) s.statusRecorder.PublishEvent( proto.SystemEvent_INFO, @@ -260,38 +232,3 @@ func toNetIDs(routes []string) []route.NetID { } return netIDs } - -func isExitNodeRoutes(routes []*route.Route) bool { - return len(routes) > 0 && (route.IsV4DefaultRoute(routes[0].Network) || route.IsV6DefaultRoute(routes[0].Network)) -} - -// requestActivatesExitNode reports whether any requested NetID maps to an exit -// node (default route) in the current route table. -func requestActivatesExitNode(requested []route.NetID, routesMap map[route.NetID][]*route.Route) bool { - for _, id := range requested { - if isExitNodeRoutes(routesMap[id]) { - return true - } - } - return false -} - -// otherExitNodeIDs returns every available exit-node NetID that is not in the -// requested set — the siblings to deselect so a single exit node stays active. -func otherExitNodeIDs(routesMap map[route.NetID][]*route.Route, requested []route.NetID) []route.NetID { - keep := make(map[route.NetID]struct{}, len(requested)) - for _, id := range requested { - keep[id] = struct{}{} - } - var others []route.NetID - for id, routes := range routesMap { - if !isExitNodeRoutes(routes) { - continue - } - if _, ok := keep[id]; ok { - continue - } - others = append(others, id) - } - return others -} diff --git a/client/server/network_exitnode_test.go b/client/server/network_exitnode_test.go deleted file mode 100644 index 1c0ba0ecb..000000000 --- a/client/server/network_exitnode_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package server - -import ( - "net/netip" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/netbirdio/netbird/route" -) - -func TestExitNodeSelectionHelpers(t *testing.T) { - routesMap := map[route.NetID][]*route.Route{ - "exitA": {{Network: netip.MustParsePrefix("0.0.0.0/0")}}, - "exitB": {{Network: netip.MustParsePrefix("::/0")}}, - "lan": {{Network: netip.MustParsePrefix("192.168.0.0/16")}}, - } - - assert.True(t, requestActivatesExitNode([]route.NetID{"exitA"}, routesMap), "v4 default route is an exit node") - assert.True(t, requestActivatesExitNode([]route.NetID{"exitB"}, routesMap), "v6 default route is an exit node") - assert.False(t, requestActivatesExitNode([]route.NetID{"lan"}, routesMap), "lan route is not an exit node") - assert.False(t, requestActivatesExitNode([]route.NetID{"missing"}, routesMap), "unknown id is not an exit node") - - others := otherExitNodeIDs(routesMap, []route.NetID{"exitB"}) - assert.ElementsMatch(t, []route.NetID{"exitA"}, others, "only the other exit node is a sibling; the lan route is ignored") -} diff --git a/client/server/panic_windows.go b/client/server/panic_windows.go index 8592f12ad..4bed6662f 100644 --- a/client/server/panic_windows.go +++ b/client/server/panic_windows.go @@ -3,6 +3,7 @@ package server import ( + "errors" "fmt" "os" "path" @@ -69,7 +70,7 @@ func setStdHandle(f *os.File) error { handle := f.Fd() r0, _, e1 := setStdHandleFn.Call(stdErrorHandle, handle) if r0 == 0 { - if e1 != nil { + if !errors.Is(e1, syscall.Errno(0)) { return e1 } return syscall.EINVAL diff --git a/client/server/server.go b/client/server/server.go index 2b919d58d..108aa8a41 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -23,6 +23,10 @@ import ( "github.com/netbirdio/netbird/client/internal/auth" "github.com/netbirdio/netbird/client/internal/expose" + "github.com/netbirdio/netbird/client/internal/ipcauth" + "github.com/prometheus/client_golang/prometheus" + + "github.com/netbirdio/netbird/client/internal/localmetrics" "github.com/netbirdio/netbird/client/internal/profilemanager" sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler" "github.com/netbirdio/netbird/client/mdm" @@ -35,6 +39,7 @@ import ( "github.com/netbirdio/netbird/client/internal/statemanager" "github.com/netbirdio/netbird/client/internal/updater" "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/util" "github.com/netbirdio/netbird/util/capture" "github.com/netbirdio/netbird/version" ) @@ -72,6 +77,9 @@ type Server struct { // RegisterUILog. Guarded by mutex. Consumed by DebugBundle so the bundle // can collect the GUI log even though the daemon runs as root and can't // resolve the user's config dir. Last-writer-wins (one UI per socket). + // DebugBundle opens it on behalf of the bundle requester and refuses a file + // that caller does not own, so a local user cannot read another user's log + // or a root-only file through it. uiLogPath string oauthAuthFlow oauthAuthFlow @@ -82,6 +90,12 @@ type Server struct { // extend flow or vice versa. extendAuthSessionFlow *auth.PendingFlow + // guardedConfigMu serializes a privilege check against the write it + // authorizes. Without it the two are separate steps over the same file, and a + // change that was allowed because the profile had the SSH server disabled + // could land after a concurrent privileged request enabled it. + guardedConfigMu sync.Mutex + mutex sync.Mutex config *profilemanager.Config proto.UnimplementedDaemonServiceServer @@ -99,6 +113,7 @@ type Server struct { statusRecorder *peer.Status sessionWatcher *internal.SessionWatcher + localMetrics *localmetrics.Manager probeThrottle *probeThrottle persistSyncResponse bool @@ -123,15 +138,39 @@ type Server struct { // stopped by the rootCtx cancellation. mdmTicker *mdm.Ticker + // mdmLoader is the daemon-owned source of the active MDM policy. + // Constructed once during Server.Start (with a nil PolicyFetcher on + // desktop — the build-tagged Loader.loadPlatform reads the OS + // registry / plist directly) and injected into every consumer: + // mdmTicker for its periodic reload, the SetConfig / Login MDM + // gates for conflict detection, and every Config produced via + // getConfig() so its apply() picks up the same overlay. + mdmLoader *mdm.Loader + updateManager *updater.Manager jwtCache *jwtCache + + // loginAttemptFn stands in for the Management login round trip. Tests set + // it to drive the login outcomes that need a server on the other end; + // production leaves it nil, and every login goes through loginAttempt. + loginAttemptFn func(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) + + isLoginRequiredFn func(ctx context.Context) (bool, error) } type oauthAuthFlow struct { - expiresAt time.Time - flow auth.OAuthFlow - info auth.AuthFlowInfo + expiresAt time.Time + flow auth.OAuthFlow + info auth.AuthFlowInfo + + // cacheGeneration is the SSH JWT cache's generation as of the start of the + // request that created this flow. The flow outlives a profile switch, so + // reading the generation any later — when the IdP has answered, or when the + // token finally arrives — would read the new session's one and let the old + // session's token into the new session's cache. + cacheGeneration uint64 + waitCancel context.CancelFunc } @@ -155,9 +194,28 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable s.sleepHandler = sleephandler.New(agent) s.startSleepDetector() + s.localMetrics = localmetrics.NewManager(ctx, s.statusRecorder, s.clientMetricsGatherer) + return s } +// clientMetricsGatherer returns the Prometheus gatherer of the running +// engine's client metrics, or nil when no engine is running. +func (s *Server) clientMetricsGatherer() prometheus.Gatherer { + s.mutex.Lock() + connectClient := s.connectClient + s.mutex.Unlock() + + if connectClient == nil { + return nil + } + engine := connectClient.Engine() + if engine == nil { + return nil + } + return engine.GetClientMetrics().PrometheusGatherer() +} + func (s *Server) Start() error { s.mutex.Lock() defer s.mutex.Unlock() @@ -197,8 +255,14 @@ func (s *Server) Start() error { // Runs re-resolves Config (re-running profilemanager.Config.apply which // applies the freshly-read MDM policy as the last layer) and brings // the engine back with the new values. + if s.mdmLoader == nil { + // Desktop builds pass a nil PolicyFetcher: the Loader's + // build-tagged loadPlatform reads the OS source directly + // (registry on Windows, plist on macOS, no-op elsewhere). + s.mdmLoader = mdm.NewLoader(nil) + } if s.mdmTicker == nil { - s.mdmTicker = mdm.NewTicker(mdm.DefaultReloadInterval) + s.mdmTicker = mdm.NewTicker(mdm.DefaultReloadInterval, s.mdmLoader) go s.mdmTicker.Run(s.rootCtx, s.onMDMPolicyChange) } @@ -238,6 +302,7 @@ func (s *Server) Start() error { s.statusRecorder.UpdateManagementAddress(config.ManagementURL.String()) s.statusRecorder.UpdateRosenpass(config.RosenpassEnabled, config.RosenpassPermissive) + s.localMetrics.Reconcile(config.LocalMetricsEnabled, config.LocalMetricsAddress) if s.sessionWatcher == nil { s.sessionWatcher = internal.NewSessionWatcher(s.rootCtx, s.statusRecorder) @@ -361,7 +426,34 @@ func (s *Server) connectionGoroutineRunning() bool { } } -// loginAttempt attempts to login using the provided information. it returns a status in case something fails +// attemptLogin runs a login round trip against Management, or the stand-in a +// test installed in place of it. +func (s *Server) attemptLogin(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) { + if s.loginAttemptFn != nil { + return s.loginAttemptFn(ctx, setupKey, jwtToken) + } + return s.loginAttempt(ctx, setupKey, jwtToken) +} + +func (s *Server) isLoginRequired(ctx context.Context) (bool, error) { + if s.isLoginRequiredFn != nil { + return s.isLoginRequiredFn(ctx) + } + + authClient, err := auth.NewAuth(ctx, s.config.PrivateKey, s.config.ManagementURL, s.config) + if err != nil { + log.Errorf("failed to create auth client: %v", err) + return false, err + } + defer authClient.Close() + + return authClient.IsLoginRequired(ctx) +} + +// loginAttempt attempts to login using the provided information. It returns +// StatusNeedsLogin when Management refused the peer's credentials and +// StatusLoginFailed for every other failure, so callers can tell an +// authentication decision apart from a login that never got made. func (s *Server) loginAttempt(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) { authClient, err := auth.NewAuth(ctx, s.config.PrivateKey, s.config.ManagementURL, s.config) if err != nil { @@ -387,6 +479,16 @@ func (s *Server) loginAttempt(ctx context.Context, setupKey, jwtToken string) (i // Login uses setup key to prepare configuration for the daemon. func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigRequest) (*proto.SetConfigResponse, error) { + // Privilege gate: refuse the parts of the request that would let a local + // user turn the root daemon into a root shell. Held across the write so the + // config cannot gain the SSH server between the decision and the update. + // + // Taken before s.mutex: authorizeAndPrepareLogin takes s.mutex while holding + // guardedConfigMu, so acquiring the two in the other order here would let a + // concurrent login deadlock the daemon. + s.guardedConfigMu.Lock() + defer s.guardedConfigMu.Unlock() + s.mutex.Lock() defer s.mutex.Unlock() @@ -406,21 +508,36 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques // by the active MDM policy. The error carries an MDMManagedFields- // Violation detail listing the offending key names. Non-conflicting // fields in the same request are not applied either. - policy := loadMDMPolicy() + policy := s.mdmLoader.Load() if err := rejectMDMManagedFieldConflicts(mdmManagedFieldConflicts(msg, policy)); err != nil { return nil, err } + stored, err := s.storedProfileConfig(msg.ProfileName, msg.Username) + if err != nil { + return nil, err + } + if err := requirePrivilegeForConfigChange(callerCtx, stored, privilegedChangeFromSetConfig(msg)); err != nil { + return nil, err + } + config, err := s.setConfigInputFromRequest(msg) if err != nil { return nil, err } - if _, err := profilemanager.UpdateConfig(config); err != nil { + updatedConf, err := profilemanager.UpdateConfig(config) + if err != nil { log.Errorf("failed to update profile config: %v", err) return nil, fmt.Errorf("failed to update profile config: %w", err) } + if activeProf, err := s.profileManager.GetActiveProfileState(); err == nil { + if activePath, err := activeProf.FilePath(); err == nil && activePath == config.ConfigPath { + s.localMetrics.Reconcile(updatedConf.LocalMetricsEnabled, updatedConf.LocalMetricsAddress) + } + } + return &proto.SetConfigResponse{}, nil } @@ -490,8 +607,11 @@ func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profile config.RosenpassEnabled = msg.RosenpassEnabled config.RosenpassPermissive = msg.RosenpassPermissive + config.LocalMetricsEnabled = msg.EnableLocalMetrics + config.LocalMetricsAddress = msg.LocalMetricsAddress config.DisableAutoConnect = msg.DisableAutoConnect config.ServerSSHAllowed = msg.ServerSSHAllowed + config.RemoteJobsAllowed = msg.RemoteJobsAllowed config.NetworkMonitor = msg.NetworkMonitor config.DisableClientRoutes = msg.DisableClientRoutes config.DisableServerRoutes = msg.DisableServerRoutes @@ -531,55 +651,54 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro if s.checkUpdateSettingsDisabled() { return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled) } - policy := loadMDMPolicy() + policy := s.mdmLoader.Load() if err := rejectMDMManagedFieldConflicts(loginRequestMDMConflicts(msg, policy)); err != nil { return nil, err } } - s.mutex.Lock() - if s.actCancel != nil { - s.actCancel() - } - ctx, cancel := context.WithCancel(callerCtx) - - md, ok := metadata.FromIncomingContext(callerCtx) - if ok { - ctx = metadata.NewOutgoingContext(ctx, md) - } - - s.actCancel = cancel - s.mutex.Unlock() - - if err := RestoreResidualState(s.rootCtx, s.profileManager.GetStatePath()); err != nil { - log.Warnf(errRestoreResidualState, err) - } - - state := internal.CtxGetState(s.rootCtx) - defer func() { - status, err := state.Status() - if err != nil || (status != internal.StatusNeedsLogin && status != internal.StatusLoginFailed) { - state.Set(internal.StatusIdle) - } - }() - activeProf, err := s.profileManager.GetActiveProfileState() if err != nil { log.Errorf("failed to get active profile state: %v", err) return nil, fmt.Errorf("failed to get active profile state: %w", err) } - if msg.ProfileName != nil { - if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil { - log.Errorf("failed to switch profile: %v", err) - return nil, err - } + // Privilege gate: same restrictions as SetConfig, since LoginRequest can carry + // the same fields. It runs before anything here changes daemon state, so a + // refused login neither switches the profile nor cancels a login already in + // progress, and it reads the profile the request targets, which is the one the + // switch below would activate. + stored, err := s.storedLoginConfig(activeProf, msg) + if err != nil { + return nil, err + } + if err := requirePrivilegeForConfigChange(callerCtx, stored, privilegedChangeFromLogin(msg)); err != nil { + return nil, err } - activeProf, err = s.profileManager.GetActiveProfileState() + state := internal.CtxGetState(s.rootCtx) + status := state.CurrentStatus() + if status == internal.StatusConnected { + return &proto.LoginResponse{}, nil + } + + defer func() { + status, err := state.Status() + if err != nil || (status != internal.StatusNeedsLogin && status != internal.StatusLoginFailed) { + state.Set(internal.StatusIdle) + } + }() + + ctx, activeProf, err := s.authorizeAndPrepareLogin(callerCtx, msg, activeProf) if err != nil { - log.Errorf("failed to get active profile state: %v", err) - return nil, fmt.Errorf("failed to get active profile state: %w", err) + // The RPC boundary is where this gets recorded: nothing logs handler + // errors for us, and a caller that retries would otherwise leave no + // trace in the daemon log. A refusal is skipped because the gate has + // already logged the decision, with the caller's identity. + if gstatus.Code(err) != codes.PermissionDenied { + log.Errorf("failed to prepare login: %v", err) + } + return nil, err } log.Infof("active profile: %s for %s", activeProf.ID, activeProf.Username) @@ -593,11 +712,6 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro s.mutex.Unlock() - if err := persistLoginOverrides(activeProf, msg.ManagementUrl, msg.OptionalPreSharedKey); err != nil { - log.Errorf("failed to persist login overrides: %v", err) - return nil, fmt.Errorf("persist login overrides: %w", err) - } - config, _, err := s.getConfig(activeProf) if err != nil { log.Errorf("failed to get active profile config: %v", err) @@ -607,60 +721,27 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro s.config = config s.mutex.Unlock() - if _, err := s.loginAttempt(ctx, "", ""); err == nil { + s.localMetrics.Reconcile(config.LocalMetricsEnabled, config.LocalMetricsAddress) + + // A probe that errors leaves the login undecided: Management unreachable, a + // restart mid-request, an internal error. Those are returned for the caller + // to retry, because turning them into an SSO prompt asks the user to solve + // something that is not theirs to solve, and a browser login cannot succeed + // while Management is unreachable anyway. Only Management refusing the + // peer's key is a decision, and IsLoginRequired reports that as + // needsLogin=true rather than an error. + needsLogin, err := s.isLoginRequired(ctx) + if err != nil { + state.Set(internal.StatusLoginFailed) + return nil, err + } + if !needsLogin { state.Set(internal.StatusIdle) return &proto.LoginResponse{}, nil } if msg.SetupKey == "" { - hint := "" - if msg.Hint != nil { - hint = *msg.Hint - } - oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.IsUnixDesktopClient, false, hint) - if err != nil { - state.Set(internal.StatusLoginFailed) - return nil, err - } - - if s.oauthAuthFlow.flow != nil && s.oauthAuthFlow.flow.GetClientID(ctx) == oAuthFlow.GetClientID(ctx) { - if s.oauthAuthFlow.expiresAt.After(time.Now().Add(90 * time.Second)) { - log.Debugf("using previous oauth flow info") - state.Set(internal.StatusNeedsLogin) - return &proto.LoginResponse{ - NeedsSSOLogin: true, - VerificationURI: s.oauthAuthFlow.info.VerificationURI, - VerificationURIComplete: s.oauthAuthFlow.info.VerificationURIComplete, - UserCode: s.oauthAuthFlow.info.UserCode, - }, nil - } else { - log.Warnf("canceling previous waiting execution") - if s.oauthAuthFlow.waitCancel != nil { - s.oauthAuthFlow.waitCancel() - } - } - } - - authInfo, err := oAuthFlow.RequestAuthInfo(ctx) - if err != nil { - log.Errorf("getting a request OAuth flow failed: %v", err) - return nil, err - } - - s.mutex.Lock() - s.oauthAuthFlow.flow = oAuthFlow - s.oauthAuthFlow.info = authInfo - s.oauthAuthFlow.expiresAt = time.Now().Add(time.Duration(authInfo.ExpiresIn) * time.Second) - s.mutex.Unlock() - - state.Set(internal.StatusNeedsLogin) - - return &proto.LoginResponse{ - NeedsSSOLogin: true, - VerificationURI: authInfo.VerificationURI, - VerificationURIComplete: authInfo.VerificationURIComplete, - UserCode: authInfo.UserCode, - }, nil + return s.beginSSOLogin(ctx, config, msg) } // Setup-key path: we are about to dial Management with the key, so the @@ -668,7 +749,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro // which returns NeedsLogin and parks on the browser leg. state.Set(internal.StatusConnecting) - if loginStatus, err := s.loginAttempt(ctx, msg.SetupKey, ""); err != nil { + if loginStatus, err := s.attemptLogin(ctx, msg.SetupKey, ""); err != nil { state.Set(loginStatus) return nil, err } @@ -676,6 +757,76 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro return &proto.LoginResponse{}, nil } +// beginSSOLogin starts the browser leg of a login that carries no setup key and +// returns the response that parks the caller on it. +func (s *Server) beginSSOLogin(ctx context.Context, config *profilemanager.Config, msg *proto.LoginRequest) (*proto.LoginResponse, error) { + state := internal.CtxGetState(s.rootCtx) + + hint := "" + if msg.Hint != nil { + hint = *msg.Hint + } + oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.IsUnixDesktopClient, false, hint) + if err != nil { + state.Set(internal.StatusLoginFailed) + return nil, err + } + + if resp := s.pendingOAuthFlowResponse(ctx, oAuthFlow); resp != nil { + state.Set(internal.StatusNeedsLogin) + return resp, nil + } + + authInfo, err := oAuthFlow.RequestAuthInfo(ctx) + if err != nil { + log.Errorf("getting a request OAuth flow failed: %v", err) + return nil, err + } + + s.mutex.Lock() + s.oauthAuthFlow.flow = oAuthFlow + s.oauthAuthFlow.info = authInfo + s.oauthAuthFlow.expiresAt = time.Now().Add(time.Duration(authInfo.ExpiresIn) * time.Second) + s.mutex.Unlock() + + state.Set(internal.StatusNeedsLogin) + + return &proto.LoginResponse{ + NeedsSSOLogin: true, + VerificationURI: authInfo.VerificationURI, + VerificationURIComplete: authInfo.VerificationURIComplete, + UserCode: authInfo.UserCode, + }, nil +} + +// pendingOAuthFlowResponse returns the in-flight flow's response when it +// targets the same IdP client and has enough time left for the user to finish +// the browser leg, so a second login joins the pending flow instead of opening +// a competing one. A flow too close to expiry has its waiter cancelled and nil +// returned, leaving the caller to start a fresh flow. +func (s *Server) pendingOAuthFlowResponse(ctx context.Context, oAuthFlow auth.OAuthFlow) *proto.LoginResponse { + if s.oauthAuthFlow.flow == nil || s.oauthAuthFlow.flow.GetClientID(ctx) != oAuthFlow.GetClientID(ctx) { + return nil + } + + if s.oauthAuthFlow.expiresAt.After(time.Now().Add(90 * time.Second)) { + log.Debugf("using previous oauth flow info") + return &proto.LoginResponse{ + NeedsSSOLogin: true, + VerificationURI: s.oauthAuthFlow.info.VerificationURI, + VerificationURIComplete: s.oauthAuthFlow.info.VerificationURIComplete, + UserCode: s.oauthAuthFlow.info.UserCode, + } + } + + log.Warnf("canceling previous waiting execution") + if s.oauthAuthFlow.waitCancel != nil { + s.oauthAuthFlow.waitCancel() + } + + return nil +} + // WaitSSOLogin validates the supplied userCode against the in-flight OAuth // device/PKCE flow and blocks until the user finishes the browser leg. // @@ -823,7 +974,7 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin s.oauthAuthFlow.expiresAt = time.Now() s.mutex.Unlock() - if loginStatus, err := s.loginAttempt(ctx, "", tokenInfo.GetTokenToUse()); err != nil { + if loginStatus, err := s.attemptLogin(ctx, "", tokenInfo.GetTokenToUse()); err != nil { state.Set(loginStatus) return nil, err } @@ -945,6 +1096,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR s.statusRecorder.UpdateManagementAddress(s.config.ManagementURL.String()) s.statusRecorder.UpdateRosenpass(s.config.RosenpassEnabled, s.config.RosenpassPermissive) + s.localMetrics.Reconcile(s.config.LocalMetricsEnabled, s.config.LocalMetricsAddress) s.clientRunning = true s.clientRunningChan = make(chan struct{}) @@ -980,6 +1132,63 @@ func (s *Server) waitForUp(callerCtx context.Context) (*proto.UpResponse, error) } } +// storedProfileConfig loads the on-disk config of the profile a request +// targets, so a privileged-change decision can be made against the values the +// profile currently holds. A profile that has no config file yet yields nil, +// which every caller must read as "nothing enabled yet". +func (s *Server) storedProfileConfig(handle, username string) (*profilemanager.Config, error) { + resolved, err := s.resolveProfileHandle(handle, username) + if err != nil { + return nil, err + } + + path := resolved.Path + if path == "" { + path = profilemanager.DefaultConfigPath + } + + return s.storedConfigAtPath(path) +} + +// storedLoginConfig loads the on-disk config of the profile a login request +// targets: the one it names, or the active one when it names none. Used to decide +// a privileged change before the request is allowed to switch profiles. +func (s *Server) storedLoginConfig(activeProf *profilemanager.ActiveProfileState, msg *proto.LoginRequest) (*profilemanager.Config, error) { + if msg.ProfileName == nil { + cfgPath, err := activeProf.FilePath() + if err != nil { + return nil, fmt.Errorf("active profile file path: %w", err) + } + return s.storedConfigAtPath(cfgPath) + } + + // Mirrors switchProfileIfNeeded: the default profile resolves without a + // username, so this reads the same profile the switch would activate. + handle := *msg.ProfileName + username := "" + if handle != profilemanager.DefaultProfileName { + username = msg.GetUsername() + } + return s.storedProfileConfig(handle, username) +} + +// storedConfigAtPath reads a profile config file, yielding nil when it does not +// exist yet. +func (s *Server) storedConfigAtPath(path string) (*profilemanager.Config, error) { + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return nil, nil //nolint:nilnil + } + return nil, fmt.Errorf("stat profile config: %w", err) + } + + cfg, err := profilemanager.GetConfig(path) + if err != nil { + return nil, fmt.Errorf("read profile config: %w", err) + } + return cfg, nil +} + // resolveProfileHandle resolves a wire-level profile handle (display // name, ID, or unique ID prefix) to a concrete profile. Returns gRPC // status errors so handlers can return them directly. @@ -1065,6 +1274,9 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi } s.config = config + s.localMetrics.Reconcile(config.LocalMetricsEnabled, config.LocalMetricsAddress) + + s.jwtCache.clear() if msg != nil && msg.ProfileName != nil { s.publishProfileListChanged(*msg.ProfileName) @@ -1081,7 +1293,10 @@ func (s *Server) Down(ctx context.Context, _ *proto.DownRequest) (*proto.DownRes if err := s.cleanupConnection(); err != nil { s.mutex.Unlock() - // todo review to update the status in case any type of error + if errors.Is(err, ErrServiceNotUp) { + log.Debugf("Down called while service not up: %v", err) + return nil, err + } log.Errorf("failed to shut down properly: %v", err) return nil, err } @@ -1154,7 +1369,7 @@ func (s *Server) cleanupConnection() error { // making the run loop the sole owner of engine shutdown. if engine != nil { if err := engine.Stop(); err != nil { - return err + log.Errorf("failed to stop engine during cleanup: %v", err) } } @@ -1188,27 +1403,56 @@ func (s *Server) handleProfileLogout(ctx context.Context, msg *proto.LogoutReque return nil, err } - if err := s.validateProfileOperation(resolved.ID, true); err != nil { + activeProf, err := s.profileManager.GetActiveProfileState() + if err != nil { + return nil, gstatus.Errorf(codes.FailedPrecondition, "failed to get active profile state: %v", err) + } + + if err := s.validateProfileLogout(resolved.ID, isActiveProfile(activeProf, resolved.ID, username)); err != nil { return nil, err } - if err := s.logoutFromProfile(ctx, resolved); err != nil { + if err := s.logoutFromProfile(ctx, resolved, username); err != nil { log.Errorf("failed to logout from profile %s: %v", resolved.ID, err) + // A refused deregistration is already a status error carrying the reason + // and the command to run; rewrapping it as Internal would flatten both + // into a gRPC dump for the user. + if _, isStatus := gstatus.FromError(err); isStatus { + return nil, err + } return nil, gstatus.Errorf(codes.Internal, "logout: %v", err) } - activeProf, _ := s.profileManager.GetActiveProfileState() - if activeProf != nil && activeProf.ID == resolved.ID { - if err := s.cleanupConnection(); err != nil && !errors.Is(err, ErrServiceNotUp) { - log.Errorf("failed to cleanup connection: %v", err) - } - state := internal.CtxGetState(s.rootCtx) - state.Set(internal.StatusNeedsLogin) - } + s.cleanupAfterProfileLogout(resolved.ID, username) return &proto.LogoutResponse{}, nil } +// cleanupAfterProfileLogout tears the connection down and asks for a new login +// when the profile that was just deregistered is the one the daemon is running. +// The active profile is read again here rather than reused from the pre-flight +// check: Login switches profiles under guardedConfigMu, which this path does not +// hold, so a login that landed meanwhile must not have its fresh connection +// dropped by a logout that targeted the profile it replaced. +func (s *Server) cleanupAfterProfileLogout(id profilemanager.ID, username string) { + activeProf, err := s.profileManager.GetActiveProfileState() + if err != nil { + log.Errorf("failed to get active profile state after logout from profile %s: %v", id, err) + return + } + + if !isActiveProfile(activeProf, id, username) { + return + } + + if err := s.cleanupConnection(); err != nil && !errors.Is(err, ErrServiceNotUp) { + log.Errorf("failed to cleanup connection: %v", err) + } + s.jwtCache.clear() + state := internal.CtxGetState(s.rootCtx) + state.Set(internal.StatusNeedsLogin) +} + func (s *Server) handleActiveProfileLogout(ctx context.Context) (*proto.LogoutResponse, error) { if s.config == nil { activeProf, err := s.profileManager.GetActiveProfileState() @@ -1233,6 +1477,7 @@ func (s *Server) handleActiveProfileLogout(ctx context.Context) (*proto.LogoutRe log.Errorf("failed to cleanup connection: %v", err) return nil, err } + s.jwtCache.clear() state := internal.CtxGetState(s.rootCtx) state.Set(internal.StatusNeedsLogin) @@ -1257,43 +1502,56 @@ func (s *Server) getConfig(activeProf *profilemanager.ActiveProfileState) (*prof return nil, false, fmt.Errorf("failed to get config: %w", err) } + // Apply the daemon-owned MDM policy on top of the just-resolved + // Config. profilemanager's apply() initialises the policy to + // empty — the Loader lives outside Config, so this overlay step + // is driven externally here. + config.ApplyMDMPolicy(s.mdmLoader.Load()) + return config, configExisted, nil } -func (s *Server) canRemoveProfile(id profilemanager.ID) error { - if id == profilemanager.DefaultProfileName { - return fmt.Errorf("remove profile with reserved name: %s", profilemanager.DefaultProfileName) - } - - activeProf, err := s.profileManager.GetActiveProfileState() - if err == nil && activeProf.ID == id { - return fmt.Errorf("remove active profile: %s", id) - } - - return nil -} - -func (s *Server) validateProfileOperation(id profilemanager.ID, allowActiveProfile bool) error { - if s.checkProfilesDisabled() { - return gstatus.Errorf(codes.Unavailable, errProfilesDisabled) - } - +// validateProfileLogout gates a profile-addressed logout. Deregistering the +// profile the daemon already runs is what a plain `netbird logout` does, so the +// profiles-disabled kill switch must not block it. Logging out of any other +// profile is profile management and stays gated. +func (s *Server) validateProfileLogout(id profilemanager.ID, isActive bool) error { if id == "" { return gstatus.Errorf(codes.InvalidArgument, "profile name must be provided") } - if !allowActiveProfile { - if err := s.canRemoveProfile(id); err != nil { - return gstatus.Errorf(codes.InvalidArgument, "%v", err) - } + if isActive { + return nil + } + + if s.checkProfilesDisabled() { + return gstatus.Errorf(codes.Unavailable, errProfilesDisabled) } return nil } -func (s *Server) logoutFromProfile(ctx context.Context, profile *profilemanager.Profile) error { +// isActiveProfile reports whether id is the profile the daemon runs for +// username. The username is part of the comparison because legacy profile IDs +// are display names, which two users can both hold; the default profile is +// shared by every user and carries no username. +func isActiveProfile(activeProf *profilemanager.ActiveProfileState, id profilemanager.ID, username string) bool { + if activeProf == nil || activeProf.ID != id { + return false + } + + return id == profilemanager.DefaultProfileName || activeProf.Username == username +} + +// logoutFromProfile deregisters profile, reusing the running config when +// profile is the one the daemon is connected with. The username takes part in +// that decision for the same reason it does in the logout gate: a legacy +// profile ID is a display name two users can share, and sending the running +// config for a namesake would deregister the active peer instead of the +// requested one. +func (s *Server) logoutFromProfile(ctx context.Context, profile *profilemanager.Profile, username string) error { activeProf, err := s.profileManager.GetActiveProfileState() - if err == nil && activeProf.ID == profile.ID && s.connectClient != nil { + if err == nil && isActiveProfile(activeProf, profile.ID, username) && s.connectClient != nil { return s.sendLogoutRequest(ctx) } @@ -1306,6 +1564,9 @@ func (s *Server) logoutFromProfile(ctx context.Context, profile *profilemanager. if err != nil { return fmt.Errorf("profile '%s' not found", profile.ID) } + // Honour any MDM-enforced ManagementURL when issuing the logout + // RPC: the user-stored value may have been overridden by policy. + config.ApplyMDMPolicy(s.mdmLoader.Load()) return s.sendLogoutRequestWithConfig(ctx, config) } @@ -1315,6 +1576,13 @@ func (s *Server) sendLogoutRequest(ctx context.Context) error { } func (s *Server) sendLogoutRequestWithConfig(ctx context.Context, config *profilemanager.Config) error { + // Privilege gate: deregistering frees this machine's key to be registered + // against another management server, which is only restricted while the SSH + // server makes that a privilege handover. + if err := requirePrivilegeForDeregistration(ctx, config); err != nil { + return err + } + key, err := wgtypes.ParseKey(config.PrivateKey) if err != nil { return fmt.Errorf("parse private key: %w", err) @@ -1550,6 +1818,20 @@ func (s *Server) getJWTCacheTTL() time.Duration { return ttl } +// cachedJWT returns the cached SSH JWT to the identity that obtained it, and a +// miss on a control channel that carries no caller identity. +func (s *Server) cachedJWT(ctx context.Context) (string, bool) { + caller, ok := ipcauth.CallerIdentity(ctx) + if !ok { + // Expected and handled on a control channel with no peer identity: the + // caller re-authenticates. daemonServerOptions warns about it once at + // startup, so this stays out of the per-request log. + log.Debug("not serving the cached SSH JWT: the caller's identity cannot be verified on this control channel") + return "", false + } + return s.jwtCache.get(caller) +} + // RequestJWTAuth initiates JWT authentication flow for SSH func (s *Server) RequestJWTAuth( ctx context.Context, @@ -1559,8 +1841,14 @@ func (s *Server) RequestJWTAuth( return nil, ctx.Err() } + // The generation is read here, with the config and under the same lock, not + // where the flow is stored below: RequestAuthInfo talks to the IdP in + // between, and a switch or a logout during that call would otherwise be + // read as the generation this flow belongs to. SwitchProfile holds + // s.mutex across its own clear(), so the pair cannot be torn. s.mutex.Lock() config := s.config + cacheGeneration := s.jwtCache.currentGeneration() s.mutex.Unlock() if config == nil { @@ -1569,7 +1857,7 @@ func (s *Server) RequestJWTAuth( jwtCacheTTL := s.getJWTCacheTTL() if jwtCacheTTL > 0 { - if cachedToken, found := s.jwtCache.get(); found { + if cachedToken, found := s.cachedJWT(ctx); found { log.Debugf("JWT token found in cache, returning cached token for SSH authentication") return &proto.RequestJWTAuthResponse{ @@ -1588,8 +1876,8 @@ func (s *Server) RequestJWTAuth( hint = profilemanager.GetLoginHint() } - isDesktop := isUnixRunningDesktop() - oAuthFlow, err := auth.NewOAuthFlow(ctx, config, isDesktop, false, hint) + // the daemon has no graphical session of its own, only the caller can answer this + oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.GetHasGraphicalSession(), false, hint) if err != nil { return nil, gstatus.Errorf(codes.Internal, "failed to create OAuth flow: %v", err) } @@ -1603,6 +1891,7 @@ func (s *Server) RequestJWTAuth( s.oauthAuthFlow.flow = oAuthFlow s.oauthAuthFlow.info = authInfo s.oauthAuthFlow.expiresAt = time.Now().Add(time.Duration(authInfo.ExpiresIn) * time.Second) + s.oauthAuthFlow.cacheGeneration = cacheGeneration s.mutex.Unlock() return &proto.RequestJWTAuthResponse{ @@ -1627,6 +1916,10 @@ func (s *Server) WaitJWTToken( s.mutex.Lock() oAuthFlow := s.oauthAuthFlow.flow authInfo := s.oauthAuthFlow.info + // Recorded when the flow was created, not read here: the flow survives a + // profile switch, and everything from RequestJWTAuth to the IdP answering + // has to count as the same session for the cache. + generation := s.oauthAuthFlow.cacheGeneration s.mutex.Unlock() if oAuthFlow == nil || authInfo.DeviceCode != req.DeviceCode { @@ -1641,11 +1934,17 @@ func (s *Server) WaitJWTToken( token := tokenInfo.GetTokenToUse() jwtCacheTTL := s.getJWTCacheTTL() - if jwtCacheTTL > 0 { - s.jwtCache.store(token, jwtCacheTTL) - log.Debugf("JWT token cached for SSH authentication, TTL: %v", jwtCacheTTL) - } else { + switch caller, ok := ipcauth.CallerIdentity(ctx); { + case jwtCacheTTL <= 0: log.Debug("JWT caching disabled, not storing token") + case !ok: + log.Debug("not caching the SSH JWT: the caller's identity cannot be verified on this control channel") + default: + if s.jwtCache.store(token, caller, jwtCacheTTL, generation) { + log.Debugf("JWT token cached for SSH authentication, TTL: %v", jwtCacheTTL) + } else { + log.Debug("not caching the SSH JWT: the session it was obtained under ended while the IdP was polled") + } } s.mutex.Lock() @@ -1680,6 +1979,9 @@ func (s *Server) RequestExtendAuthSession( if connectClient == nil { return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not running") } + if connectClient.Engine() == nil { + return nil, gstatus.Errorf(codes.FailedPrecondition, "session can no longer be extended, log in again to reconnect") + } hint := "" if msg.Hint != nil { @@ -1689,8 +1991,8 @@ func (s *Server) RequestExtendAuthSession( hint = profilemanager.GetLoginHint() } - isDesktop := isUnixRunningDesktop() - oAuthFlow, err := auth.NewOAuthFlow(ctx, config, isDesktop, false, hint) + // the daemon has no graphical session of its own, only the caller can answer this + oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.GetHasGraphicalSession(), false, hint) if err != nil { return nil, gstatus.Errorf(codes.Internal, "failed to create OAuth flow: %v", err) } @@ -1862,13 +2164,6 @@ func (s *Server) ExposeService(req *proto.ExposeServiceRequest, srv proto.Daemon return nil } -func isUnixRunningDesktop() bool { - if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" { - return false - } - return os.Getenv("DESKTOP_SESSION") != "" || os.Getenv("XDG_CURRENT_DESKTOP") != "" -} - func (s *Server) runProbes(ctx context.Context, waitForProbeResult bool) { if s.connectClient == nil { return @@ -1906,6 +2201,11 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p log.Errorf("failed to get active profile config: %v", err) return nil, fmt.Errorf("failed to get active profile config: %w", err) } + // Overlay the active MDM policy so the response's MDMManagedFields + // list reflects what the GUI / CLI must render as read-only. + // profilemanager.GetConfig itself returns a Config without the + // overlay (Loader lives outside profilemanager). + cfg.ApplyMDMPolicy(s.mdmLoader.Load()) managementURL := cfg.ManagementURL adminURL := cfg.AdminURL @@ -1969,6 +2269,7 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p Mtu: int64(cfg.MTU), DisableAutoConnect: cfg.DisableAutoConnect, ServerSSHAllowed: *cfg.ServerSSHAllowed, + RemoteJobsAllowed: util.ReturnBoolWithDefaultFalse(cfg.RemoteJobsAllowed), RosenpassEnabled: cfg.RosenpassEnabled, RosenpassPermissive: cfg.RosenpassPermissive, BlockInbound: cfg.BlockInbound, @@ -2059,8 +2360,11 @@ func (s *Server) RemoveProfile(ctx context.Context, msg *proto.RemoveProfileRequ return nil, err } - if err := s.logoutFromProfile(ctx, resolved); err != nil { - log.Warnf("failed to logout from profile %s before removal: %v", resolved.ID, err) + if err := s.logoutFromProfile(ctx, resolved, msg.Username); err != nil { + // Deregistration is best-effort here: the local profile is removed + // either way, so an unprivileged caller leaves the peer registered on + // the management server rather than being blocked from removing it. + log.Warnf("removing profile %s locally without deregistering it: %v", resolved.ID, err) } if err := s.profileManager.RemoveProfile(resolved.ID, msg.Username); err != nil { @@ -2357,6 +2661,69 @@ func sendTerminalNotification() error { // persistLoginOverrides writes management URL and pre-shared key from a LoginRequest to the // active profile config so that subsequent reads pick them up. Empty/nil values are ignored. +// afterLoginPreCheck is a seam for tests to run a concurrent config change +// between Login's first privilege check and the authoritative one. +var afterLoginPreCheck func() + +// authorizeAndPrepareLogin makes the authoritative privilege decision for a login +// and, when it passes, carries out every state change that decision authorizes: +// cancelling an login already in progress, switching to the requested profile, and +// persisting the config overrides the request carries. +// +// All of it happens under guardedConfigMu, which SetConfig also holds across its +// own check and write. Login's earlier check refuses the ordinary case before any +// of this is reached; this one exists because that check is not synchronized +// against a concurrent privileged request that enables the SSH server, and a +// caller refused here must not have cancelled or switched anything either. +func (s *Server) authorizeAndPrepareLogin(callerCtx context.Context, msg *proto.LoginRequest, activeProf *profilemanager.ActiveProfileState) (context.Context, *profilemanager.ActiveProfileState, error) { + if afterLoginPreCheck != nil { + afterLoginPreCheck() + } + + s.guardedConfigMu.Lock() + defer s.guardedConfigMu.Unlock() + + stored, err := s.storedLoginConfig(activeProf, msg) + if err != nil { + return nil, nil, err + } + if err := requirePrivilegeForConfigChange(callerCtx, stored, privilegedChangeFromLogin(msg)); err != nil { + return nil, nil, err + } + + s.mutex.Lock() + if s.actCancel != nil { + s.actCancel() + } + ctx, cancel := context.WithCancel(callerCtx) + if md, ok := metadata.FromIncomingContext(callerCtx); ok { + ctx = metadata.NewOutgoingContext(ctx, md) + } + s.actCancel = cancel + s.mutex.Unlock() + + if err := RestoreResidualState(s.rootCtx, s.profileManager.GetStatePath()); err != nil { + log.Warnf(errRestoreResidualState, err) + } + + if msg.ProfileName != nil { + if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil { + return nil, nil, fmt.Errorf("switch profile: %w", err) + } + } + + activeProf, err = s.profileManager.GetActiveProfileState() + if err != nil { + return nil, nil, fmt.Errorf("active profile state: %w", err) + } + + if err := persistLoginOverrides(activeProf, msg.ManagementUrl, msg.OptionalPreSharedKey); err != nil { + return nil, nil, fmt.Errorf("persist login overrides: %w", err) + } + + return ctx, activeProf, nil +} + func persistLoginOverrides(activeProf *profilemanager.ActiveProfileState, managementURL string, preSharedKey *string) error { if preSharedKey != nil && *preSharedKey == "" { preSharedKey = nil diff --git a/client/server/server_connect_test.go b/client/server/server_connect_test.go index 0c6e03a4a..dc191a44f 100644 --- a/client/server/server_connect_test.go +++ b/client/server/server_connect_test.go @@ -18,6 +18,10 @@ func newTestServer() *Server { return &Server{ rootCtx: context.Background(), statusRecorder: peer.NewRecorder(""), + // New always populates the SSH JWT cache and the logout and + // profile-switch paths call into it unconditionally, so a Server + // assembled field by field has to populate it too. + jwtCache: newJWTCache(), } } diff --git a/client/server/server_jwt_test.go b/client/server/server_jwt_test.go new file mode 100644 index 000000000..3fec5598a --- /dev/null +++ b/client/server/server_jwt_test.go @@ -0,0 +1,188 @@ +package server + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/auth" + "github.com/netbirdio/netbird/client/internal/localmetrics" + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/proto" +) + +// These cover the RPC side of the cache: the cache itself is exercised in +// jwt_cache_test.go, but a correct cache buys nothing if the handlers around it +// consult the wrong identity or forget to clear it. + +func TestCachedJWT_ServesTheOwner(t *testing.T) { + s := newTestServer() + owner := unprivilegedIdentity() + s.jwtCache.store("token", owner, testTTL, s.jwtCache.currentGeneration()) + + got, found := s.cachedJWT(ctxWithIdentity(owner)) + + require.True(t, found, "the identity that obtained the token must get it back") + assert.Equal(t, "token", got) +} + +func TestCachedJWT_RefusesAnotherCaller(t *testing.T) { + s := newTestServer() + s.jwtCache.store("token", unprivilegedIdentity(), testTTL, s.jwtCache.currentGeneration()) + + got, found := s.cachedJWT(ctxWithIdentity(privilegedIdentity())) + + assert.False(t, found, "a caller that did not obtain the token must get a miss") + assert.Empty(t, got) +} + +// A control channel that carries no caller identity — a TCP daemon socket, or a +// platform with no peer-credential primitive — cannot tell one local user from +// another, so cachedJWT must fail closed there. +func TestCachedJWT_WithoutCallerIdentity(t *testing.T) { + s := newTestServer() + s.jwtCache.store("token", unprivilegedIdentity(), testTTL, s.jwtCache.currentGeneration()) + + got, found := s.cachedJWT(context.Background()) + + assert.False(t, found) + assert.Empty(t, got) +} + +// profileFixture points the profile globals at a temp dir holding a single +// default profile, which is the one ActiveProfileState.FilePath resolves +// without consulting the current OS user. +func profileFixture(t *testing.T) string { + t.Helper() + + dir := t.TempDir() + defaultConfig := filepath.Join(dir, "default.json") + require.NoError(t, os.WriteFile(defaultConfig, []byte("{}"), 0o600)) + + origDir := profilemanager.DefaultConfigPathDir + origDefault := profilemanager.DefaultConfigPath + origState := profilemanager.ActiveProfileStatePath + origOverride := profilemanager.ConfigDirOverride + + profilemanager.DefaultConfigPathDir = dir + profilemanager.DefaultConfigPath = defaultConfig + profilemanager.ActiveProfileStatePath = filepath.Join(dir, "active_profile.json") + profilemanager.ConfigDirOverride = dir + + t.Cleanup(func() { + profilemanager.DefaultConfigPathDir = origDir + profilemanager.DefaultConfigPath = origDefault + profilemanager.ActiveProfileStatePath = origState + profilemanager.ConfigDirOverride = origOverride + }) + + return defaultConfig +} + +// A profile carries its own NetBird account, so a token obtained under the +// previous one must not survive the switch even for the local user who +// obtained it. +func TestSwitchProfile_ClearsJWTCache(t *testing.T) { + defaultConfig := profileFixture(t) + + // localmetrics.NewManager runs until its context is done, so the manager + // must not outlive the test. + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + s := newTestServer() + s.profileManager = profilemanager.NewServiceManager(defaultConfig) + s.localMetrics = localmetrics.NewManager(ctx, s.statusRecorder, nil) + + // A second profile to move to, so the request goes through + // switchProfileIfNeeded rather than the no-op path a nil request takes. + const target = "second" + username := "tester" + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, target+".json"), + ManagementURL: "https://api.netbird.io:443", + }) + require.NoError(t, err) + + owner := unprivilegedIdentity() + s.jwtCache.store("token", owner, testTTL, s.jwtCache.currentGeneration()) + + name := target + _, err = s.SwitchProfile(ctx, &proto.SwitchProfileRequest{ProfileName: &name, Username: &username}) + require.NoError(t, err) + + active, err := s.profileManager.GetActiveProfileState() + require.NoError(t, err) + require.Equal(t, profilemanager.ID(target), active.ID, "the profile must actually have changed") + + _, found := s.jwtCache.get(owner) + assert.False(t, found, "switching profile must drop the cached SSH JWT") +} + +// Down ends the connection, not the session: the peer stays enrolled and the +// token still belongs to the same NetBird identity, so `down` followed by `up` +// must not cost the owner a fresh device-code flow. +// +// The logout handlers do call cleanupConnection, and SwitchProfile does not; +// what they have in common is that each clears the cache itself, right after, +// so tearing the connection down is no longer what decides the token's fate. +func TestCleanupConnection_KeepsJWTCache(t *testing.T) { + s := newTestServer() + _, cancel := context.WithCancel(context.Background()) + s.actCancel = cancel + + owner := unprivilegedIdentity() + s.jwtCache.store("token", owner, testTTL, s.jwtCache.currentGeneration()) + + require.NoError(t, s.cleanupConnection()) + + got, found := s.jwtCache.get(owner) + require.True(t, found, "going down must not drop the cached SSH JWT") + assert.Equal(t, "token", got) +} + +// fakeOAuthFlow stands in for the IdP round trip so a test can drive +// WaitJWTToken without a real device-code flow. +type fakeOAuthFlow struct { + token string +} + +func (f *fakeOAuthFlow) RequestAuthInfo(context.Context) (auth.AuthFlowInfo, error) { + return auth.AuthFlowInfo{DeviceCode: "device-code"}, nil +} + +func (f *fakeOAuthFlow) WaitToken(context.Context, auth.AuthFlowInfo) (auth.TokenInfo, error) { + return auth.TokenInfo{AccessToken: f.token}, nil +} + +func (f *fakeOAuthFlow) GetClientID(context.Context) string { return "client-id" } + +// The flow outlives a profile switch, because SwitchProfile does not reset +// s.oauthAuthFlow. A switch between RequestJWTAuth and the IdP answering must +// still keep the token out of the cache the new profile uses, and the +// generation the flow carries is what decides it: reading the cache's own +// generation at store time would already be the new one. +func TestWaitJWTToken_DropsTokenFromASessionThatEndedBeforeTheWait(t *testing.T) { + s := newTestServer() + owner := unprivilegedIdentity() + ttl := int(testTTL.Seconds()) + s.config = &profilemanager.Config{SSHJWTCacheTTL: &ttl} + + // RequestJWTAuth ran under the previous session and recorded its generation. + s.oauthAuthFlow.flow = &fakeOAuthFlow{token: "token-from-the-old-session"} + s.oauthAuthFlow.info = auth.AuthFlowInfo{DeviceCode: "device-code"} + s.oauthAuthFlow.cacheGeneration = s.jwtCache.currentGeneration() + + // A profile switch or a logout lands before the caller reaches WaitJWTToken. + s.jwtCache.clear() + + _, err := s.WaitJWTToken(ctxWithIdentity(owner), &proto.WaitJWTTokenRequest{DeviceCode: "device-code"}) + require.NoError(t, err) + + _, found := s.jwtCache.get(owner) + assert.False(t, found, "a token whose flow started under the previous session must not be cached") +} diff --git a/client/server/server_privileged_test.go b/client/server/server_privileged_test.go index 8b6f78f04..aa6e99026 100644 --- a/client/server/server_privileged_test.go +++ b/client/server/server_privileged_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" @@ -200,7 +200,7 @@ func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Serve requestBuffer := server.NewAccountRequestBuffer(context.Background(), store) peersUpdateManager := update_channel.NewPeersUpdateManager(metrics) - networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config) + networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config, nil) accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore) if err != nil { return nil, "", err diff --git a/client/server/setconfig_mdm_test.go b/client/server/setconfig_mdm_test.go index 9baf16136..a392af6d3 100644 --- a/client/server/setconfig_mdm_test.go +++ b/client/server/setconfig_mdm_test.go @@ -16,14 +16,40 @@ import ( "github.com/netbirdio/netbird/client/proto" ) -// withMDMPolicy temporarily overrides the server-package loadMDMPolicy hook -// so SetConfig observes the supplied Policy. Restores the original loader -// at test cleanup. -func withMDMPolicy(t *testing.T, policy *mdm.Policy) { +// fakeMDMFetcher implements mdm.PolicyFetcher returning a pre-set +// policy map. Tests build one per Server instance to inject a +// scripted MDM overlay via a Loader rather than via package-level state. +type fakeMDMFetcher struct{ values map[string]any } + +func (f *fakeMDMFetcher) Fetch() map[string]any { return f.values } + +// withMDMPolicy installs an mdm.Loader on the given Server whose +// loadPlatform returns the supplied Policy's underlying values. Use +// after setupServerWithProfile to inject the scripted policy the +// SetConfig / Login MDM gates will observe. +func withMDMPolicy(t *testing.T, s *Server, policy *mdm.Policy) { t.Helper() - prev := loadMDMPolicy - loadMDMPolicy = func() *mdm.Policy { return policy } - t.Cleanup(func() { loadMDMPolicy = prev }) + values := map[string]any{} + if policy != nil { + for _, k := range policy.ManagedKeys() { + if v, ok := policy.GetString(k); ok { + values[k] = v + continue + } + if v, ok := policy.GetInt(k); ok { + values[k] = v + continue + } + if v, ok := policy.GetBool(k); ok { + values[k] = v + continue + } + if v, ok := policy.GetStringSlice(k); ok { + values[k] = v + } + } + } + s.mdmLoader = mdm.NewLoader(&fakeMDMFetcher{values: values}) } // setupServerWithProfile mirrors the boilerplate of TestSetConfig_AllFieldsSaved: @@ -66,7 +92,11 @@ func setupServerWithProfile(t *testing.T) (s *Server, ctx context.Context, profN Username: currUser.Username, })) - ctx = context.Background() + // The privileged-change gate reads the caller's kernel identity from the + // context, which a real caller gets from the daemon's transport credentials. + // This test drives the handler directly, so it stands in for a root caller; + // without an identity the gate would (correctly) refuse the SSH fields. + ctx = privilegedTestCtx() s = New(ctx, "console", "", false, false, false, false) return s, ctx, profName, currUser.Username, cfgPath } @@ -89,12 +119,11 @@ func extractViolation(t *testing.T, err error) *proto.MDMManagedFieldsViolation } func TestSetConfig_MDMReject_SingleField(t *testing.T) { - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ mdm.KeyManagementURL: "https://mdm.example.com:443", })) - s, ctx, profName, username, _ := setupServerWithProfile(t) - _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ ProfileName: profName, Username: username, @@ -106,14 +135,13 @@ func TestSetConfig_MDMReject_SingleField(t *testing.T) { } func TestSetConfig_MDMReject_MultipleFields(t *testing.T) { - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ mdm.KeyManagementURL: "https://mdm.example.com:443", mdm.KeyBlockInbound: true, mdm.KeyRosenpassEnabled: true, })) - s, ctx, profName, username, _ := setupServerWithProfile(t) - blockInbound := false rosenpassEnabled := false _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ @@ -132,17 +160,123 @@ func TestSetConfig_MDMReject_MultipleFields(t *testing.T) { }, v.GetFields()) } +func TestSetConfig_MDMReject_LocalMetrics(t *testing.T) { + s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ + mdm.KeyEnableLocalMetrics: true, + mdm.KeyLocalMetricsAddress: "127.0.0.1:9191", + })) + + enabled := false + addr := "0.0.0.0:9999" + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + EnableLocalMetrics: &enabled, + LocalMetricsAddress: &addr, + }) + + v := extractViolation(t, err) + assert.ElementsMatch(t, []string{ + mdm.KeyEnableLocalMetrics, + mdm.KeyLocalMetricsAddress, + }, v.GetFields()) +} + +// An explicitly empty address still changes the effective listen address +// (the manager falls back to the default), so presence must be honored +// rather than collapsed to "field not set". +func TestSetConfig_MDMReject_LocalMetricsEmptyAddress(t *testing.T) { + s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ + mdm.KeyLocalMetricsAddress: "127.0.0.1:9999", + })) + + addr := "" + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + LocalMetricsAddress: &addr, + }) + + v := extractViolation(t, err) + assert.ElementsMatch(t, []string{mdm.KeyLocalMetricsAddress}, v.GetFields()) +} + +func TestSetConfig_MDMReject_EmptyPreSharedKey(t *testing.T) { + s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ + mdm.KeyPreSharedKey: "mdm-enforced-psk", + })) + + psk := "" + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + OptionalPreSharedKey: &psk, + }) + + v := extractViolation(t, err) + assert.ElementsMatch(t, []string{mdm.KeyPreSharedKey}, v.GetFields()) +} + +func TestSetConfig_MDMAllow_PreSharedKeySentinelEcho(t *testing.T) { + s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ + mdm.KeyPreSharedKey: "mdm-enforced-psk", + })) + + psk := mdm.PreSharedKeyRedactedSentinel + resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + OptionalPreSharedKey: &psk, + }) + + require.NoError(t, err) + require.NotNil(t, resp) +} + +func TestLoginRequestMDMConflicts_PreSharedKey(t *testing.T) { + policy := mdm.NewPolicy(map[string]any{ + mdm.KeyPreSharedKey: "mdm-enforced-psk", + }) + empty := "" + sentinel := mdm.PreSharedKeyRedactedSentinel + same := "mdm-enforced-psk" + other := "user-psk" + + tests := []struct { + name string + msg *proto.LoginRequest + want []string + }{ + {name: "unset", msg: &proto.LoginRequest{}, want: nil}, + {name: "optional empty", msg: &proto.LoginRequest{OptionalPreSharedKey: &empty}, want: []string{mdm.KeyPreSharedKey}}, + {name: "optional sentinel echo", msg: &proto.LoginRequest{OptionalPreSharedKey: &sentinel}, want: nil}, + {name: "optional same value", msg: &proto.LoginRequest{OptionalPreSharedKey: &same}, want: nil}, + {name: "optional divergent", msg: &proto.LoginRequest{OptionalPreSharedKey: &other}, want: []string{mdm.KeyPreSharedKey}}, + {name: "legacy empty is unset", msg: &proto.LoginRequest{PreSharedKey: ""}, want: nil}, //nolint:staticcheck // SA1019: legacy proto field still accepted by Login + {name: "legacy sentinel echo", msg: &proto.LoginRequest{PreSharedKey: sentinel}, want: nil}, //nolint:staticcheck // SA1019: legacy proto field still accepted by Login + {name: "legacy divergent", msg: &proto.LoginRequest{PreSharedKey: other}, want: []string{mdm.KeyPreSharedKey}}, //nolint:staticcheck // SA1019: legacy proto field still accepted by Login + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, loginRequestMDMConflicts(tc.msg, policy)) + }) + } +} + func TestSetConfig_MDMReject_AllOrNothing(t *testing.T) { // MDM enforces ManagementURL only; user request touches both the // enforced field AND a non-enforced field (RosenpassEnabled). // The whole request must be rejected — non-conflicting fields are not // applied either. - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + s, ctx, profName, username, cfgPath := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ mdm.KeyManagementURL: "https://mdm.example.com:443", })) - s, ctx, profName, username, cfgPath := setupServerWithProfile(t) - rosenpassEnabled := true _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ ProfileName: profName, @@ -164,12 +298,11 @@ func TestSetConfig_MDMReject_AllOrNothing(t *testing.T) { func TestSetConfig_MDMAllow_NonManagedFields(t *testing.T) { // MDM enforces ManagementURL but the user only writes RosenpassEnabled. // Request must succeed. - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ mdm.KeyManagementURL: "https://mdm.example.com:443", })) - s, ctx, profName, username, _ := setupServerWithProfile(t) - rosenpassEnabled := true resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{ ProfileName: profName, @@ -198,12 +331,11 @@ func TestSetConfig_MDMAllow_ManagementURLPortNormalized(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{ mdm.KeyManagementURL: tc.mdmURL, })) - s, ctx, profName, username, _ := setupServerWithProfile(t) - rosenpassEnabled := true resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{ ProfileName: profName, @@ -220,9 +352,8 @@ func TestSetConfig_MDMAllow_ManagementURLPortNormalized(t *testing.T) { func TestSetConfig_MDMEmpty_NoEnforcement(t *testing.T) { // No MDM policy active: any field can be written. - withMDMPolicy(t, mdm.NewPolicy(nil)) - s, ctx, profName, username, _ := setupServerWithProfile(t) + withMDMPolicy(t, s, mdm.NewPolicy(nil)) resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{ ProfileName: profName, diff --git a/client/server/setconfig_test.go b/client/server/setconfig_test.go index 0e55257a9..7442b718e 100644 --- a/client/server/setconfig_test.go +++ b/client/server/setconfig_test.go @@ -1,7 +1,6 @@ package server import ( - "context" "os/user" "path/filepath" "reflect" @@ -52,12 +51,17 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { }) require.NoError(t, err) - ctx := context.Background() + // The privileged-change gate reads the caller's kernel identity from the + // context, which a real caller gets from the daemon's transport credentials. + // This test drives the handler directly, so it stands in for a root caller; + // without an identity the gate would (correctly) refuse the SSH fields. + ctx := privilegedTestCtx() s := New(ctx, "console", "", false, false, false, false) rosenpassEnabled := true rosenpassPermissive := true serverSSHAllowed := true + remoteJobsAllowed := true interfaceName := "utun100" wireguardPort := int64(51820) preSharedKey := "test-psk" @@ -73,6 +77,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { disableIPv6 := true mtu := int64(1280) sshJWTCacheTTL := int32(300) + enableLocalMetrics := true + localMetricsAddress := "127.0.0.1:9292" req := &proto.SetConfigRequest{ ProfileName: profName, @@ -82,6 +88,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { RosenpassEnabled: &rosenpassEnabled, RosenpassPermissive: &rosenpassPermissive, ServerSSHAllowed: &serverSSHAllowed, + RemoteJobsAllowed: &remoteJobsAllowed, InterfaceName: &interfaceName, WireguardPort: &wireguardPort, OptionalPreSharedKey: &preSharedKey, @@ -104,6 +111,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { DnsRouteInterval: durationpb.New(2 * time.Minute), Mtu: &mtu, SshJWTCacheTTL: &sshJWTCacheTTL, + EnableLocalMetrics: &enableLocalMetrics, + LocalMetricsAddress: &localMetricsAddress, } _, err = s.SetConfig(ctx, req) @@ -125,6 +134,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { require.Equal(t, rosenpassPermissive, cfg.RosenpassPermissive) require.NotNil(t, cfg.ServerSSHAllowed) require.Equal(t, serverSSHAllowed, *cfg.ServerSSHAllowed) + require.NotNil(t, cfg.RemoteJobsAllowed) + require.Equal(t, remoteJobsAllowed, *cfg.RemoteJobsAllowed) require.Equal(t, interfaceName, cfg.WgIface) require.Equal(t, int(wireguardPort), cfg.WgPort) require.Equal(t, preSharedKey, cfg.PreSharedKey) @@ -150,6 +161,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { require.Equal(t, uint16(mtu), cfg.MTU) require.NotNil(t, cfg.SSHJWTCacheTTL) require.Equal(t, int(sshJWTCacheTTL), *cfg.SSHJWTCacheTTL) + require.Equal(t, enableLocalMetrics, cfg.LocalMetricsEnabled) + require.Equal(t, localMetricsAddress, cfg.LocalMetricsAddress) verifyAllFieldsCovered(t, req) } @@ -177,6 +190,7 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) { "RosenpassEnabled": true, "RosenpassPermissive": true, "ServerSSHAllowed": true, + "RemoteJobsAllowed": true, "InterfaceName": true, "WireguardPort": true, "OptionalPreSharedKey": true, @@ -202,6 +216,8 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) { "EnableSSHRemotePortForwarding": true, "DisableSSHAuth": true, "SshJWTCacheTTL": true, + "EnableLocalMetrics": true, + "LocalMetricsAddress": true, } val := reflect.ValueOf(req).Elem() @@ -237,6 +253,7 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) { "enable-rosenpass": "RosenpassEnabled", "rosenpass-permissive": "RosenpassPermissive", "allow-server-ssh": "ServerSSHAllowed", + "allow-remote-jobs": "RemoteJobsAllowed", "interface-name": "InterfaceName", "wireguard-port": "WireguardPort", "preshared-key": "OptionalPreSharedKey", @@ -261,6 +278,8 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) { "enable-ssh-remote-port-forwarding": "EnableSSHRemotePortForwarding", "disable-ssh-auth": "DisableSSHAuth", "ssh-jwt-cache-ttl": "SshJWTCacheTTL", + "enable-local-metrics": "EnableLocalMetrics", + "local-metrics-address": "LocalMetricsAddress", } // SetConfigRequest fields that don't have CLI flags (settable only via UI or other means). diff --git a/client/server/ssh_gate.go b/client/server/ssh_gate.go new file mode 100644 index 000000000..01d24687e --- /dev/null +++ b/client/server/ssh_gate.go @@ -0,0 +1,351 @@ +package server + +import ( + "context" + "fmt" + "net/url" + "runtime" + "strings" + + log "github.com/sirupsen/logrus" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal/daemonaddr" + "github.com/netbirdio/netbird/client/internal/ipcauth" + "github.com/netbirdio/netbird/client/internal/localmetrics" + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/util" +) + +// The daemon runs as root/LocalSystem, so a handful of config changes cross the +// user-to-root boundary and are restricted to privileged callers: +// +// - Enabling SSH root login, or disabling SSH authentication, turns the +// daemon's SSH server into a root (or unauthenticated) shell. +// - Enabling the SSH server at all is what makes the above reachable, and a +// profile the caller owns is not a privilege they hold. +// - While the SSH server is enabled, repointing the profile at another +// management identity hands SSH authorization decisions, including which +// keys and users are accepted, to whoever controls that identity. Changing +// the management URL and deregistering the peer are both ways to do that. +// - Binding the local metrics endpoint to a non-loopback address publishes +// peer names and connectivity state to the network without authentication. +// +// Everything else stays unauthenticated, so this is not an authorization model: +// it only refuses the changes that would let a local user become root. A caller +// whose identity cannot be established is refused as well. + +// privilegedConfigChange is the subset of a config request that crosses the +// user-to-root boundary. Fields are nil or empty when the request leaves them +// untouched. +type privilegedConfigChange struct { + managementURL string + serverSSHAllowed *bool + remoteJobsAllowed *bool + enableSSHRoot *bool + disableSSHAuth *bool + enableLocalMetrics *bool + localMetricsAddress *string +} + +func privilegedChangeFromSetConfig(msg *proto.SetConfigRequest) privilegedConfigChange { + return privilegedConfigChange{ + managementURL: msg.GetManagementUrl(), + serverSSHAllowed: msg.ServerSSHAllowed, + remoteJobsAllowed: msg.RemoteJobsAllowed, + enableSSHRoot: msg.EnableSSHRoot, + disableSSHAuth: msg.DisableSSHAuth, + enableLocalMetrics: msg.EnableLocalMetrics, + localMetricsAddress: msg.LocalMetricsAddress, + } +} + +func privilegedChangeFromLogin(msg *proto.LoginRequest) privilegedConfigChange { + return privilegedConfigChange{ + managementURL: msg.GetManagementUrl(), + serverSSHAllowed: msg.ServerSSHAllowed, + remoteJobsAllowed: msg.RemoteJobsAllowed, + enableSSHRoot: msg.EnableSSHRoot, + disableSSHAuth: msg.DisableSSHAuth, + enableLocalMetrics: msg.EnableLocalMetrics, + localMetricsAddress: msg.LocalMetricsAddress, + } +} + +// requirePrivilegeForConfigChange refuses the privileged parts of a config +// change when the caller is not root/administrator. stored is the profile's +// current config, or nil when it has none yet. +// +// Each check compares against the stored value so that a request restating a +// value it does not change is never refused: a UI that submits the whole +// settings form must not start failing once an administrator has enabled SSH. +func requirePrivilegeForConfigChange(ctx context.Context, stored *profilemanager.Config, change privilegedConfigChange) error { + if enables(storedFlag(stored, func(c *profilemanager.Config) *bool { return c.EnableSSHRoot }), change.enableSSHRoot) { + return denyPrivileged(ctx, "enabling SSH root login", ipcauth.UpCommand("--enable-ssh-root")) + } + + if enables(storedFlag(stored, func(c *profilemanager.Config) *bool { return c.DisableSSHAuth }), change.disableSSHAuth) { + return denyPrivileged(ctx, "disabling SSH authentication", ipcauth.UpCommand("--disable-ssh-auth")) + } + + if enables(sshServerCurrentlyAllowed(stored), change.serverSSHAllowed) { + return denyPrivileged(ctx, "enabling the NetBird SSH server", ipcauth.UpCommand("--allow-server-ssh")) + } + + // Enabling remote jobs lets the management server run jobs (e.g. debug + // bundles) on this host, so turning it on crosses the user-to-root + // boundary the same way enabling the SSH server does. The stored value + // defaults to off (nil = off), so a legacy config is correctly seen as + // off and turning it on requires privilege. + if enables(storedFlag(stored, func(c *profilemanager.Config) *bool { return c.RemoteJobsAllowed }), change.remoteJobsAllowed) { + return denyPrivileged(ctx, "enabling remote jobs", ipcauth.UpCommand("--allow-remote-jobs")) + } + + if addr, exposes := exposesLocalMetrics(stored, change); exposes { + return denyPrivileged(ctx, + "exposing the local metrics endpoint on a non-loopback address", + ipcauth.UpCommand("--enable-local-metrics --local-metrics-address "+addr)) + } + + // Only guard the management binding while the SSH server is enabled: that is + // when the management identity decides who may open a shell here. + if !sshServerEnabled(stored) { + return nil + } + + if change.managementURL != "" && !sameManagementURL(stored.ManagementURL, change.managementURL) { + return denyPrivileged(ctx, + "changing the management URL while the NetBird SSH server is enabled", + ipcauth.UpCommand("-m "+change.managementURL)) + } + + return nil +} + +// requirePrivilegeForDeregistration refuses to deregister the peer from the +// management server when the caller is not privileged and the profile has the +// SSH server enabled. Deregistering frees the peer's key to be registered +// against another management identity, which is the same handover the +// management URL check refuses. +// +// Callers that treat deregistration as best-effort (profile removal) continue +// without it; callers that were asked to deregister surface the error. +func requirePrivilegeForDeregistration(ctx context.Context, cfg *profilemanager.Config) error { + if !sshServerEnabled(cfg) { + return nil + } + + return denyPrivileged(ctx, + "deregistering this peer while the NetBird SSH server is enabled", + ipcauth.ElevatedCommand("netbird logout")) +} + +// denyPrivileged returns nil when the caller is privileged, and otherwise a +// PermissionDenied whose message names the action and the command that performs +// it with the privileges it needs. The same summary and command ride along as an +// ErrorInfo detail so the CLI and the UI can present them without parsing text. +// +// action reads as the subject of a sentence ("enabling SSH root login"), and +// command is the equivalent command, already elevated for the platform. +func denyPrivileged(ctx context.Context, action, command string) error { + id, ok := ipcauth.CallerIdentity(ctx) + if !ok { + log.Warnf("denying %s: the caller's identity cannot be verified on this control channel", action) + return privilegeError(unidentifiedSummary(action), reinstallCommand()) + } + + if ipcauth.IsPrivilegedCaller(id) { + log.Infof("allowing %s for privileged caller %s", action, id) + return nil + } + + log.Warnf("denying %s for unprivileged caller %s", action, id) + actor, command := requiredActor(command) + return privilegeError(privilegeSummary(action, actor), command) +} + +// requiredActor names who may perform the operation and adjusts the command to +// match. A daemon that is not itself privileged delegates to its own identity, so +// telling that host's user to become root is wrong twice over: root is not what the +// daemon checks for, and a rootless container has neither root nor sudo. +func requiredActor(command string) (string, string) { + self, delegates := ipcauth.SelfDelegatesTo() + if !delegates { + return ipcauth.PrivilegedActor(), command + } + return fmt.Sprintf("the user the daemon runs as (%s)", self), strings.ReplaceAll(command, "sudo ", "") +} + +// privilegeError builds the PermissionDenied carrying summary and command. +func privilegeError(summary, command string) error { + st := gstatus.New(codes.PermissionDenied, fmt.Sprintf("%s\n\n%s", summary, command)) + + detailed, err := st.WithDetails(&errdetails.ErrorInfo{ + Reason: ipcauth.ErrorReasonPrivilegeRequired, + Domain: ipcauth.ErrorDomain, + Metadata: map[string]string{ + ipcauth.ErrorMetaSummary: summary, + ipcauth.ErrorMetaCommand: command, + }, + }) + if err != nil { + log.Debugf("attach privilege error detail: %v", err) + return st.Err() + } + return detailed.Err() +} + +// privilegeSummary states what is refused and what it needs, in one sentence +// that reads the same in a dialog and in a terminal. +func privilegeSummary(action, actor string) string { + return fmt.Sprintf("%s requires %s.", capitalize(action), actor) +} + +// unidentifiedSummary covers a control channel that carries no caller identity. +// Elevating does not help there, so it points at the daemon's socket instead. +func unidentifiedSummary(action string) string { + return fmt.Sprintf("%s requires %s, and the daemon cannot verify who is calling over its current socket. "+ + "Reinstall the service on a socket that carries the caller's identity.", capitalize(action), ipcauth.PrivilegedActor()) +} + +// reinstallCommand is the command that moves the daemon onto a socket whose +// callers can be identified. +func reinstallCommand() string { + if runtime.GOOS == "windows" { + return fmt.Sprintf("netbird service install --daemon-addr %s", daemonaddr.WindowsPipeAddr) + } + return "sudo netbird service install --daemon-addr unix:///var/run/netbird.sock" +} + +func capitalize(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +// enables reports whether requested turns a flag on that is currently off. A +// request that restates the stored value, or turns the flag off, is not a +// privileged change. +func enables(stored, requested *bool) bool { + if requested == nil || !*requested { + return false + } + return stored == nil || !*stored +} + +// storedFlag reads a flag from the stored config, tolerating a config that does +// not exist yet. +func storedFlag(cfg *profilemanager.Config, get func(*profilemanager.Config) *bool) *bool { + if cfg == nil { + return nil + } + return get(cfg) +} + +// sshServerEnabled reports whether the profile currently runs the SSH server. +// +// A nil flag means ON, matching what the engine does with the same config +// (util.ReturnBoolWithDefaultTrue in internal/connect.go, kept for configs written +// before the flag existed). Reading it as OFF here would open the management-URL +// and deregistration guards on exactly those legacy hosts, whose SSH server is +// running. Configs loaded through profilemanager have already been materialised by +// apply(), so this is the same answer by a route that does not depend on that. +func sshServerEnabled(cfg *profilemanager.Config) bool { + if cfg == nil { + return false + } + return util.ReturnBoolWithDefaultTrue(cfg.ServerSSHAllowed) +} + +// sshServerCurrentlyAllowed is the value an enable request is compared against. It +// shares sshServerEnabled's nil-means-on default, so restating "on" for a legacy +// config is correctly seen as no change. +func sshServerCurrentlyAllowed(cfg *profilemanager.Config) *bool { + enabled := sshServerEnabled(cfg) + if cfg == nil { + return nil + } + return &enabled +} + +// exposesLocalMetrics reports whether the change would leave the metrics +// endpoint enabled on an address that is not confirmed loopback, and returns +// that address. A request that restates the stored state is not a change, so a +// settings form resubmitted after an administrator opened the endpoint is not +// refused. +func exposesLocalMetrics(stored *profilemanager.Config, change privilegedConfigChange) (string, bool) { + storedEnabled, storedAddr := storedLocalMetrics(stored) + + enabled := storedEnabled + if change.enableLocalMetrics != nil { + enabled = *change.enableLocalMetrics + } + addr := storedAddr + if change.localMetricsAddress != nil { + addr = metricsAddrOrDefault(*change.localMetricsAddress) + } + + if !enabled || localmetrics.IsLoopback(addr) { + return "", false + } + if storedEnabled && storedAddr == addr { + return "", false + } + return addr, true +} + +// storedLocalMetrics reads the metrics settings from the stored config, +// tolerating a config that does not exist yet. +func storedLocalMetrics(cfg *profilemanager.Config) (bool, string) { + if cfg == nil { + return false, localmetrics.DefaultListenAddress + } + return cfg.LocalMetricsEnabled, metricsAddrOrDefault(cfg.LocalMetricsAddress) +} + +func metricsAddrOrDefault(addr string) string { + if addr == "" { + return localmetrics.DefaultListenAddress + } + return addr +} + +// sameManagementURL reports whether requested addresses the same management +// server as stored, comparing scheme, host and effective port so that an +// equivalent spelling ("https://api.netbird.io" for a stored +// "https://api.netbird.io:443") is not treated as a change. It fails closed: +// anything unparseable counts as a change and therefore needs privilege. +func sameManagementURL(stored *url.URL, requested string) bool { + if stored == nil { + return false + } + + // Normalise the requested URL through the config layer's own parser, so the + // comparison cannot drift from how the value would actually be stored. + parsed, err := profilemanager.ParseServiceURL("Management URL", requested) + if err != nil { + return false + } + + return stored.Scheme == parsed.Scheme && + stored.Hostname() == parsed.Hostname() && + effectivePort(stored) == effectivePort(parsed) +} + +func effectivePort(u *url.URL) string { + if port := u.Port(); port != "" { + return port + } + switch u.Scheme { + case "https": + return "443" + case "http": + return "80" + default: + return "" + } +} diff --git a/client/server/ssh_gate_test.go b/client/server/ssh_gate_test.go new file mode 100644 index 000000000..b4712c64a --- /dev/null +++ b/client/server/ssh_gate_test.go @@ -0,0 +1,474 @@ +package server + +import ( + "context" + "net/url" + "os" + "runtime" + "strings" + "testing" + + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/peer" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal/ipcauth" + "github.com/netbirdio/netbird/client/internal/profilemanager" +) + +// ctxWithIdentity builds a request context carrying the identity the transport +// credentials would have attached. +func ctxWithIdentity(id ipcauth.Identity) context.Context { + return peer.NewContext(context.Background(), &peer.Peer{ + AuthInfo: ipcauth.AuthInfo{ + CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity}, + Identity: id, + }, + }) +} + +// unprivUID is deliberately not this process's own uid. An unprivileged daemon +// treats a caller sharing its identity as privileged (rootless containers), and +// the test binary would otherwise stand in for both the daemon and the caller. +// os.Geteuid returns -1 on Windows, where identities are SIDs instead and this is +// unused. +var unprivUID = uint32(os.Geteuid() + 1) + +// The fabricated identities have to be shaped like the platform's: a uid says +// nothing on Windows, and a zero uid there would read as root and be privileged. +func rootCtx() context.Context { return ctxWithIdentity(privilegedIdentity()) } +func userCtx() context.Context { return ctxWithIdentity(unprivilegedIdentity()) } + +func privilegedIdentity() ipcauth.Identity { + if runtime.GOOS == "windows" { + // LocalSystem, which is what the Windows service account is. + return ipcauth.Identity{SID: "S-1-5-18"} + } + return ipcauth.Identity{UID: 0} +} + +func unprivilegedIdentity() ipcauth.Identity { + if runtime.GOOS == "windows" { + // A plain user SID: no groups, so no BUILTIN\Administrators, and not + // elevated. + return ipcauth.Identity{SID: "S-1-5-21-1-2-3-1001"} + } + return ipcauth.Identity{UID: unprivUID, GID: unprivUID} +} +func noIdentityCtx() context.Context { return context.Background() } + +func boolPtr(v bool) *bool { return &v } + +func strPtr(v string) *string { return &v } + +func mustURL(t *testing.T, raw string) *url.URL { + t.Helper() + u, err := url.Parse(raw) + if err != nil { + t.Fatalf("parse %q: %v", raw, err) + } + return u +} + +func assertDenied(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Fatal("expected the change to be refused, got nil") + } + st := gstatus.Convert(err) + if st.Code() != codes.PermissionDenied { + t.Fatalf("code = %v, want PermissionDenied", st.Code()) + } + // The refusal must be machine-readable: the CLI and the UI render the + // summary and command from the detail rather than parsing the message. + var info *errdetails.ErrorInfo + for _, d := range st.Details() { + if got, ok := d.(*errdetails.ErrorInfo); ok { + info = got + } + } + if info == nil { + t.Fatal("refusal carries no ErrorInfo detail") + } + if info.GetReason() != ipcauth.ErrorReasonPrivilegeRequired || info.GetDomain() != ipcauth.ErrorDomain { + t.Fatalf("detail = %s/%s, want %s/%s", info.GetDomain(), info.GetReason(), ipcauth.ErrorDomain, ipcauth.ErrorReasonPrivilegeRequired) + } + if info.GetMetadata()[ipcauth.ErrorMetaSummary] == "" { + t.Error("detail carries no summary") + } + if info.GetMetadata()[ipcauth.ErrorMetaCommand] == "" { + t.Error("detail carries no command") + } +} + +func assertAllowed(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatalf("expected the change to be allowed, got %v", err) + } +} + +func TestRequirePrivilegeForConfigChange_SSHFlags(t *testing.T) { + tests := []struct { + name string + stored *profilemanager.Config + change privilegedConfigChange + privileged bool + wantDeny bool + }{ + { + name: "enabling the ssh server unprivileged is refused", + stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)}, + change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)}, + wantDeny: true, + }, + { + name: "enabling the ssh server as root is allowed", + stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)}, + change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)}, + privileged: true, + }, + { + name: "restating an already enabled ssh server is not a change", + stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)}, + change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)}, + }, + { + name: "turning the ssh server off is not guarded", + stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)}, + change: privilegedConfigChange{serverSSHAllowed: boolPtr(false)}, + }, + { + name: "a profile with no config yet counts as off, so enabling is refused", + stored: nil, + change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)}, + wantDeny: true, + }, + { + name: "enabling ssh root login unprivileged is refused", + stored: &profilemanager.Config{EnableSSHRoot: boolPtr(false)}, + change: privilegedConfigChange{enableSSHRoot: boolPtr(true)}, + wantDeny: true, + }, + { + name: "restating ssh root login is not a change", + stored: &profilemanager.Config{EnableSSHRoot: boolPtr(true)}, + change: privilegedConfigChange{enableSSHRoot: boolPtr(true)}, + }, + { + name: "turning ssh root login off is not guarded", + stored: &profilemanager.Config{EnableSSHRoot: boolPtr(true)}, + change: privilegedConfigChange{enableSSHRoot: boolPtr(false)}, + }, + { + name: "disabling ssh authentication unprivileged is refused", + stored: &profilemanager.Config{DisableSSHAuth: boolPtr(false)}, + change: privilegedConfigChange{disableSSHAuth: boolPtr(true)}, + wantDeny: true, + }, + { + name: "re-enabling ssh authentication is not guarded", + stored: &profilemanager.Config{DisableSSHAuth: boolPtr(true)}, + change: privilegedConfigChange{disableSSHAuth: boolPtr(false)}, + }, + { + name: "enabling remote jobs unprivileged is refused", + stored: &profilemanager.Config{RemoteJobsAllowed: boolPtr(false)}, + change: privilegedConfigChange{remoteJobsAllowed: boolPtr(true)}, + wantDeny: true, + }, + { + name: "enabling remote jobs as root is allowed", + stored: &profilemanager.Config{RemoteJobsAllowed: boolPtr(false)}, + change: privilegedConfigChange{remoteJobsAllowed: boolPtr(true)}, + privileged: true, + }, + { + name: "a profile with no config yet counts as off, so enabling remote jobs is refused", + stored: nil, + change: privilegedConfigChange{remoteJobsAllowed: boolPtr(true)}, + wantDeny: true, + }, + { + name: "restating already-enabled remote jobs is not a change", + stored: &profilemanager.Config{RemoteJobsAllowed: boolPtr(true)}, + change: privilegedConfigChange{remoteJobsAllowed: boolPtr(true)}, + }, + { + name: "turning remote jobs off is not guarded", + stored: &profilemanager.Config{RemoteJobsAllowed: boolPtr(true)}, + change: privilegedConfigChange{remoteJobsAllowed: boolPtr(false)}, + }, + { + name: "a request that touches none of the guarded fields is allowed", + stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)}, + change: privilegedConfigChange{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := userCtx() + if tt.privileged { + ctx = rootCtx() + } + err := requirePrivilegeForConfigChange(ctx, tt.stored, tt.change) + if tt.wantDeny { + assertDenied(t, err) + return + } + assertAllowed(t, err) + }) + } +} + +func TestRequirePrivilegeForConfigChange_LocalMetrics(t *testing.T) { + exposed := &profilemanager.Config{LocalMetricsEnabled: true, LocalMetricsAddress: "0.0.0.0:9191"} + + tests := []struct { + name string + stored *profilemanager.Config + change privilegedConfigChange + privileged bool + wantDeny bool + }{ + { + name: "binding a non-loopback address unprivileged is refused", + stored: &profilemanager.Config{}, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("0.0.0.0:9191")}, + wantDeny: true, + }, + { + name: "binding a non-loopback address as root is allowed", + stored: &profilemanager.Config{}, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("0.0.0.0:9191")}, + privileged: true, + }, + { + name: "enabling on the default loopback address is not guarded", + stored: &profilemanager.Config{}, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true)}, + }, + { + name: "enabling on an explicit loopback address is not guarded", + stored: &profilemanager.Config{}, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("127.0.0.1:9999")}, + }, + { + name: "enabling on the IPv6 loopback address is not guarded", + stored: &profilemanager.Config{}, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("[::1]:9191")}, + }, + { + // The address alone does nothing while the endpoint stays off. + name: "a non-loopback address without enabling is not guarded", + stored: &profilemanager.Config{}, + change: privilegedConfigChange{localMetricsAddress: strPtr("0.0.0.0:9191")}, + }, + { + name: "widening an already enabled loopback endpoint is refused", + stored: &profilemanager.Config{LocalMetricsEnabled: true, LocalMetricsAddress: "127.0.0.1:9191"}, + change: privilegedConfigChange{localMetricsAddress: strPtr("0.0.0.0:9191")}, + wantDeny: true, + }, + { + name: "restating an already exposed endpoint is not a change", + stored: exposed, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("0.0.0.0:9191")}, + }, + { + name: "turning an exposed endpoint off is not guarded", + stored: exposed, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(false)}, + }, + { + name: "re-enabling an exposed endpoint that was turned off is refused", + stored: &profilemanager.Config{LocalMetricsEnabled: false, LocalMetricsAddress: "0.0.0.0:9191"}, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true)}, + wantDeny: true, + }, + { + // Fail closed: an address that cannot be parsed is not confirmed loopback. + name: "an unparseable address is refused", + stored: &profilemanager.Config{}, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("not-an-address")}, + wantDeny: true, + }, + { + name: "a profile with no config yet counts as off, so exposing is refused", + stored: nil, + change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("0.0.0.0:9191")}, + wantDeny: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := userCtx() + if tt.privileged { + ctx = rootCtx() + } + err := requirePrivilegeForConfigChange(ctx, tt.stored, tt.change) + if tt.wantDeny { + assertDenied(t, err) + return + } + assertAllowed(t, err) + }) + } +} + +func TestRequirePrivilegeForConfigChange_ManagementURL(t *testing.T) { + sshOn := func(raw string) *profilemanager.Config { + return &profilemanager.Config{ServerSSHAllowed: boolPtr(true), ManagementURL: mustURL(t, raw)} + } + sshOff := func(raw string) *profilemanager.Config { + return &profilemanager.Config{ServerSSHAllowed: boolPtr(false), ManagementURL: mustURL(t, raw)} + } + + tests := []struct { + name string + stored *profilemanager.Config + requested string + privileged bool + wantDeny bool + }{ + { + name: "moving the binding while ssh is enabled is refused", + stored: sshOn("https://api.netbird.io:443"), + requested: "https://attacker.example.com:443", + wantDeny: true, + }, + { + name: "moving the binding as root is allowed", + stored: sshOn("https://api.netbird.io:443"), + requested: "https://selfhosted.example.com:443", + privileged: true, + }, + { + name: "the same url restated is not a change", + stored: sshOn("https://api.netbird.io:443"), + requested: "https://api.netbird.io:443", + }, + { + name: "an equivalent spelling of the same url is not a change", + stored: sshOn("https://api.netbird.io:443"), + requested: "https://api.netbird.io", + }, + { + name: "an equivalent spelling with an explicit http port is not a change", + stored: sshOn("http://mgmt.internal:80"), + requested: "http://mgmt.internal", + }, + { + name: "a different port on the same host is a change", + stored: sshOn("https://api.netbird.io:443"), + requested: "https://api.netbird.io:8443", + wantDeny: true, + }, + { + name: "a different scheme on the same host is a change", + stored: sshOn("https://mgmt.internal:443"), + requested: "http://mgmt.internal:443", + wantDeny: true, + }, + { + name: "with ssh disabled the binding is not guarded at all", + stored: sshOff("https://api.netbird.io:443"), + requested: "https://attacker.example.com:443", + }, + { + name: "an unparseable url fails closed", + stored: sshOn("https://api.netbird.io:443"), + requested: "ht tp://%zz", + wantDeny: true, + }, + { + name: "an empty url leaves the binding alone", + stored: sshOn("https://api.netbird.io:443"), + requested: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := userCtx() + if tt.privileged { + ctx = rootCtx() + } + err := requirePrivilegeForConfigChange(ctx, tt.stored, privilegedConfigChange{managementURL: tt.requested}) + if tt.wantDeny { + assertDenied(t, err) + return + } + assertAllowed(t, err) + }) + } +} + +// A caller the daemon cannot identify must be refused, not trusted: that is the +// state on a TCP daemon socket, where no peer credentials exist. +func TestRequirePrivilegeForConfigChange_UnidentifiedCallerIsRefused(t *testing.T) { + err := requirePrivilegeForConfigChange(noIdentityCtx(), + &profilemanager.Config{ServerSSHAllowed: boolPtr(false)}, + privilegedConfigChange{serverSSHAllowed: boolPtr(true)}) + assertDenied(t, err) + + // The guidance must point at the socket rather than at sudo, since elevating + // would not help. + st := gstatus.Convert(err) + if !strings.Contains(st.Message(), "service install") { + t.Errorf("message %q does not tell the operator how to fix the socket", st.Message()) + } +} + +func TestRequirePrivilegeForDeregistration(t *testing.T) { + tests := []struct { + name string + cfg *profilemanager.Config + privileged bool + wantDeny bool + }{ + { + name: "deregistering while ssh is enabled is refused", + cfg: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)}, + wantDeny: true, + }, + { + name: "deregistering while ssh is enabled is allowed for root", + cfg: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)}, + privileged: true, + }, + { + name: "deregistering with ssh disabled is not guarded", + cfg: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)}, + }, + { + name: "deregistering a profile with no config is not guarded", + cfg: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := userCtx() + if tt.privileged { + ctx = rootCtx() + } + err := requirePrivilegeForDeregistration(ctx, tt.cfg) + if tt.wantDeny { + assertDenied(t, err) + return + } + assertAllowed(t, err) + }) + } +} + +// privilegedTestCtx is the context a handler-level test should use when it is +// standing in for a root/administrator caller. Tests that drive the handlers +// directly have no transport credentials, and the privileged-change gate refuses +// a caller it cannot identify. +func privilegedTestCtx() context.Context { return rootCtx() } diff --git a/client/ssh/auth/auth.go b/client/ssh/auth/auth.go index 079282fdc..92f517fac 100644 --- a/client/ssh/auth/auth.go +++ b/client/ssh/auth/auth.go @@ -3,6 +3,7 @@ package auth import ( "errors" "fmt" + "slices" "sync" log "github.com/sirupsen/logrus" @@ -155,6 +156,24 @@ func (a *Authorizer) GetUserIDClaim() string { return a.userIDClaim } +// Config returns the authorization currently in force. The user list and the +// machine-user map are copies; the originals stay in use here. +func (a *Authorizer) Config() *Config { + a.mu.RLock() + defer a.mu.RUnlock() + + machineUsers := make(map[string][]uint32, len(a.machineUsers)) + for osUser, indexes := range a.machineUsers { + machineUsers[osUser] = slices.Clone(indexes) + } + + return &Config{ + UserIDClaim: a.userIDClaim, + AuthorizedUsers: slices.Clone(a.authorizedUsers), + MachineUsers: machineUsers, + } +} + // findUserIndex finds the index of a hashed user ID in the authorized users list // Returns the index and true if found, 0 and false if not found func (a *Authorizer) findUserIndex(hashedUserID sshuserhash.UserIDHash) (int, bool) { diff --git a/client/ssh/client/client.go b/client/ssh/client/client.go index ebf8eb794..31143a4f4 100644 --- a/client/ssh/client/client.go +++ b/client/ssh/client/client.go @@ -9,7 +9,6 @@ import ( "path/filepath" "runtime" "strconv" - "strings" "time" log "github.com/sirupsen/logrus" @@ -17,7 +16,6 @@ import ( "golang.org/x/crypto/ssh/knownhosts" "golang.org/x/term" "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" "github.com/netbirdio/netbird/client/internal/daemonaddr" "github.com/netbirdio/netbird/client/internal/profilemanager" @@ -32,7 +30,7 @@ const ( // DefaultDaemonAddr is the default address for the NetBird daemon DefaultDaemonAddr = "unix:///var/run/netbird.sock" // DefaultDaemonAddrWindows is the default address for the NetBird daemon on Windows - DefaultDaemonAddrWindows = "tcp://127.0.0.1:41731" + DefaultDaemonAddrWindows = daemonaddr.WindowsPipeAddr ) // Client wraps crypto/ssh Client for simplified SSH operations @@ -268,7 +266,7 @@ func getDefaultDaemonAddr() string { return addr } if runtime.GOOS == "windows" { - return DefaultDaemonAddrWindows + return daemonaddr.ResolveDaemonAddr(DefaultDaemonAddrWindows) } return daemonaddr.ResolveUnixDaemonAddr(DefaultDaemonAddr) } @@ -315,21 +313,23 @@ func Dial(ctx context.Context, addr, user string, opts DialOptions) (*Client, er // dialSSH establishes an SSH connection without JWT authentication func dialSSH(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*Client, error) { + if config.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, config.Timeout) + defer cancel() + } + dialer := &net.Dialer{} conn, err := dialer.DialContext(ctx, network, addr) if err != nil { return nil, fmt.Errorf("dial %s: %w", addr, err) } - clientConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config) + client, err := nbssh.Handshake(ctx, conn, addr, config) if err != nil { - if closeErr := conn.Close(); closeErr != nil { - log.Debugf("connection close after handshake failure: %v", closeErr) - } - return nil, fmt.Errorf("ssh handshake: %w", err) + return nil, err } - client := ssh.NewClient(clientConn, chans, reqs) return &Client{ client: client, }, nil @@ -410,12 +410,9 @@ func verifyHostKeyViaDaemon(hostname string, remote net.Addr, key ssh.PublicKey, } func connectToDaemon(daemonAddr string) (*grpc.ClientConn, error) { - addr := strings.TrimPrefix(daemonAddr, "tcp://") + target, opts := daemonaddr.DialTarget(daemonAddr) - conn, err := grpc.NewClient( - addr, - grpc.WithTransportCredentials(insecure.NewCredentials()), - ) + conn, err := grpc.NewClient(target, opts...) if err != nil { log.Debugf("failed to create gRPC client for NetBird daemon at %s: %v", daemonAddr, err) return nil, fmt.Errorf("failed to connect to NetBird daemon: %w", err) diff --git a/client/ssh/client/terminal_unix.go b/client/ssh/client/terminal_unix.go index aaa3418f9..a963dc8be 100644 --- a/client/ssh/client/terminal_unix.go +++ b/client/ssh/client/terminal_unix.go @@ -12,6 +12,8 @@ import ( log "github.com/sirupsen/logrus" "golang.org/x/crypto/ssh" "golang.org/x/term" + + nbssh "github.com/netbirdio/netbird/client/ssh" ) func (c *Client) setupTerminalMode(ctx context.Context, session *ssh.Session) error { @@ -82,37 +84,7 @@ func (c *Client) setupTerminal(session *ssh.Session, fd int) error { return fmt.Errorf("get terminal size: %w", err) } - modes := ssh.TerminalModes{ - ssh.ECHO: 1, - ssh.TTY_OP_ISPEED: 14400, - ssh.TTY_OP_OSPEED: 14400, - // Ctrl+C - ssh.VINTR: 3, - // Ctrl+\ - ssh.VQUIT: 28, - // Backspace - ssh.VERASE: 127, - // Ctrl+U - ssh.VKILL: 21, - // Ctrl+D - ssh.VEOF: 4, - ssh.VEOL: 0, - ssh.VEOL2: 0, - // Ctrl+Q - ssh.VSTART: 17, - // Ctrl+S - ssh.VSTOP: 19, - // Ctrl+Z - ssh.VSUSP: 26, - // Ctrl+O - ssh.VDISCARD: 15, - // Ctrl+R - ssh.VREPRINT: 18, - // Ctrl+W - ssh.VWERASE: 23, - // Ctrl+V - ssh.VLNEXT: 22, - } + modes := nbssh.DefaultTerminalModes terminal := os.Getenv("TERM") if terminal == "" { diff --git a/client/ssh/client/terminal_windows.go b/client/ssh/client/terminal_windows.go index 462438317..c6156fc26 100644 --- a/client/ssh/client/terminal_windows.go +++ b/client/ssh/client/terminal_windows.go @@ -10,6 +10,8 @@ import ( log "github.com/sirupsen/logrus" "golang.org/x/crypto/ssh" + + nbssh "github.com/netbirdio/netbird/client/ssh" ) const ( @@ -80,28 +82,14 @@ func (c *Client) setupTerminalMode(_ context.Context, session *ssh.Session) erro w, h := c.getWindowsConsoleSize() modes := ssh.TerminalModes{ - ssh.ECHO: 1, - ssh.TTY_OP_ISPEED: 14400, - ssh.TTY_OP_OSPEED: 14400, - ssh.ICRNL: 1, - ssh.OPOST: 1, - ssh.ONLCR: 1, - ssh.ISIG: 1, - ssh.ICANON: 1, - ssh.VINTR: 3, // Ctrl+C - ssh.VQUIT: 28, // Ctrl+\ - ssh.VERASE: 127, // Backspace - ssh.VKILL: 21, // Ctrl+U - ssh.VEOF: 4, // Ctrl+D - ssh.VEOL: 0, - ssh.VEOL2: 0, - ssh.VSTART: 17, // Ctrl+Q - ssh.VSTOP: 19, // Ctrl+S - ssh.VSUSP: 26, // Ctrl+Z - ssh.VDISCARD: 15, // Ctrl+O - ssh.VWERASE: 23, // Ctrl+W - ssh.VLNEXT: 22, // Ctrl+V - ssh.VREPRINT: 18, // Ctrl+R + ssh.ICRNL: 1, + ssh.OPOST: 1, + ssh.ONLCR: 1, + ssh.ISIG: 1, + ssh.ICANON: 1, + } + for mode, value := range nbssh.DefaultTerminalModes { + modes[mode] = value } if err := session.RequestPty("xterm-256color", h, w, modes); err != nil { diff --git a/client/ssh/common.go b/client/ssh/common.go index 92e647b7d..4ebf8842a 100644 --- a/client/ssh/common.go +++ b/client/ssh/common.go @@ -13,6 +13,7 @@ import ( "golang.org/x/crypto/ssh" "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/util" ) const ( @@ -34,6 +35,19 @@ type HostKeyVerifier interface { VerifySSHHostKey(peerAddress string, key []byte) error } +// PeerKeyLookup returns the stored SSH host key for a peer address. +type PeerKeyLookup func(peerAddress string) ([]byte, bool) + +// VerifySSHHostKey implements HostKeyVerifier by looking up the stored key +// and comparing it against the presented key. +func (l PeerKeyLookup) VerifySSHHostKey(peerAddress string, presentedKey []byte) error { + storedKey, found := l(peerAddress) + if !found { + return ErrPeerNotFound + } + return VerifyHostKey(storedKey, presentedKey, peerAddress) +} + // DaemonHostKeyVerifier implements HostKeyVerifier using the NetBird daemon type DaemonHostKeyVerifier struct { client proto.DaemonServiceClient @@ -92,7 +106,8 @@ func printAuthInstructions(stderr io.Writer, authResponse *proto.RequestJWTAuthR // RequestJWTToken requests or retrieves a JWT token for SSH authentication func RequestJWTToken(ctx context.Context, client proto.DaemonServiceClient, stdout, stderr io.Writer, useCache bool, hint string, openBrowser func(string) error) (string, error) { - req := &proto.RequestJWTAuthRequest{} + // the ssh client runs in the user's session, the daemon does not: tell it what we can see + req := &proto.RequestJWTAuthRequest{HasGraphicalSession: util.HasGraphicalSession()} if hint != "" { req.Hint = &hint } @@ -193,4 +208,3 @@ func buildAddressList(hostname string, remote net.Addr) []string { } return addresses } - diff --git a/client/ssh/config/manager.go b/client/ssh/config/manager.go index 20695cb4d..e15330739 100644 --- a/client/ssh/config/manager.go +++ b/client/ssh/config/manager.go @@ -14,6 +14,7 @@ import ( log "github.com/sirupsen/logrus" nbssh "github.com/netbirdio/netbird/client/ssh" + "github.com/netbirdio/netbird/shared/management/domain" ) const ( @@ -218,11 +219,20 @@ func (m *Manager) buildHostPatterns(peer PeerSSHInfo) []string { if peer.IPv6.IsValid() { hostPatterns = append(hostPatterns, peer.IPv6.String()) } - if peer.FQDN != "" { + // Peer FQDNs and hostnames originate from remote peers, so they must be + // validated as plain DNS names before being embedded in the ssh_config + // "Match host" pattern list. This prevents injection of arbitrary + // ssh_config directives via embedded quotes, whitespace, newlines, the + // comma pattern separator, or the "*"/"?" pattern metacharacters. + if domain.IsValidDomainNoWildcard(peer.FQDN) { hostPatterns = append(hostPatterns, peer.FQDN) + } else if peer.FQDN != "" { + log.Warnf("skipping peer FQDN with invalid characters in SSH config: %q", peer.FQDN) } - if peer.Hostname != "" && peer.Hostname != peer.FQDN { + if peer.Hostname != peer.FQDN && domain.IsValidDomainNoWildcard(peer.Hostname) { hostPatterns = append(hostPatterns, peer.Hostname) + } else if peer.Hostname != "" && peer.Hostname != peer.FQDN { + log.Warnf("skipping peer hostname with invalid characters in SSH config: %q", peer.Hostname) } return hostPatterns } diff --git a/client/ssh/config/manager_test.go b/client/ssh/config/manager_test.go index 8e6be40a3..f65d0ba6d 100644 --- a/client/ssh/config/manager_test.go +++ b/client/ssh/config/manager_test.go @@ -148,6 +148,45 @@ func TestManager_MatchHostFormat(t *testing.T) { "should use Match host with comma-separated patterns") } +func TestManager_HostPatternInjection(t *testing.T) { + tempDir, err := os.MkdirTemp("", "netbird-ssh-config-test") + require.NoError(t, err) + defer func() { assert.NoError(t, os.RemoveAll(tempDir)) }() + + manager := &Manager{ + sshConfigDir: filepath.Join(tempDir, "ssh_config.d"), + sshConfigFile: "99-netbird.conf", + } + + // A malicious peer FQDN/hostname attempts to break out of the Match host + // directive and inject arbitrary ssh_config (a ProxyCommand executing a + // command). It must be rejected, not written to the config. + peers := []PeerSSHInfo{ + { + Hostname: "evil\"\n ProxyCommand touch /tmp/pwned\nHost x", + IP: netip.MustParseAddr("100.125.1.1"), + FQDN: "evil\"\n ProxyCommand touch /tmp/pwned\nHost x.nb.internal", + }, + {Hostname: "peer2", IP: netip.MustParseAddr("100.125.1.2"), FQDN: "peer2.nb.internal"}, + } + + err = manager.SetupSSHClientConfig(peers) + require.NoError(t, err) + + configPath := filepath.Join(manager.sshConfigDir, manager.sshConfigFile) + content, err := os.ReadFile(configPath) + require.NoError(t, err) + configStr := string(content) + + assert.NotContains(t, configStr, "ProxyCommand touch /tmp/pwned", + "injected directive must not appear in generated config") + assert.NotContains(t, configStr, "evil", + "malicious pattern must be dropped entirely") + // The valid peer must still be present, on a single Match host line. + assert.Contains(t, configStr, "Match host \"100.125.1.1,100.125.1.2,peer2.nb.internal,peer2\"", + "valid peers must survive, injected patterns dropped") +} + func TestManager_ForcedSSHConfig(t *testing.T) { // Set force environment variable t.Setenv(EnvForceSSHConfig, "true") diff --git a/client/ssh/handshake.go b/client/ssh/handshake.go new file mode 100644 index 000000000..a718748df --- /dev/null +++ b/client/ssh/handshake.go @@ -0,0 +1,48 @@ +package ssh + +import ( + "context" + "fmt" + "io" + "net" + + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" +) + +// Handshake runs the SSH client handshake on an already dialed conn and +// returns the resulting client. Dialing bounds only the TCP establishment; +// a peer that accepts and then goes silent would block the handshake forever, +// so conn is closed as soon as ctx is done, which unblocks the handshake and +// surfaces the context error. conn is closed on any error. +func Handshake(ctx context.Context, conn net.Conn, addr string, config *ssh.ClientConfig) (*ssh.Client, error) { + stop := context.AfterFunc(ctx, func() { closeHandshake(conn, "conn on context done") }) + + sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config) + if err != nil { + if stop() { + closeHandshake(conn, "conn after handshake error") + } + return nil, handshakeError(ctx, err) + } + + if !stop() { + closeHandshake(sshConn, "ssh conn after context done") + return nil, fmt.Errorf("ssh handshake: %w", ctx.Err()) + } + + return ssh.NewClient(sshConn, chans, reqs), nil +} + +func closeHandshake(c io.Closer, label string) { + if err := c.Close(); err != nil { + log.Debugf("ssh: close %s: %v", label, err) + } +} + +func handshakeError(ctx context.Context, err error) error { + if ctxErr := ctx.Err(); ctxErr != nil { + return fmt.Errorf("ssh handshake: %w: %w", ctxErr, err) + } + return fmt.Errorf("ssh handshake: %w", err) +} diff --git a/client/ssh/handshake_test.go b/client/ssh/handshake_test.go new file mode 100644 index 000000000..77a6f916b --- /dev/null +++ b/client/ssh/handshake_test.go @@ -0,0 +1,90 @@ +package ssh + +import ( + "context" + "errors" + "net" + "testing" + "time" + + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" +) + +func TestHandshake_ContextDeadlineWrapped(t *testing.T) { + conn := dialSilentServer(t) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + _, err := Handshake(ctx, conn, conn.RemoteAddr().String(), testClientConfig()) + require.Error(t, err) + require.True(t, errors.Is(err, context.DeadlineExceeded), "expected context.DeadlineExceeded, got: %v", err) +} + +func TestHandshake_ContextCancelUnblocks(t *testing.T) { + conn := dialSilentServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + time.AfterFunc(50*time.Millisecond, cancel) + + errCh := make(chan error, 1) + go func() { + _, err := Handshake(ctx, conn, conn.RemoteAddr().String(), testClientConfig()) + errCh <- err + }() + + select { + case err := <-errCh: + require.Error(t, err) + require.True(t, errors.Is(err, context.Canceled), "expected context.Canceled, got: %v", err) + case <-time.After(5 * time.Second): + t.Fatal("handshake did not return after context cancellation") + } +} + +func TestHandshake_NonContextErrorNotWrapped(t *testing.T) { + conn := dialSilentServer(t) + require.NoError(t, conn.Close()) + + _, err := Handshake(context.Background(), conn, conn.RemoteAddr().String(), testClientConfig()) + require.Error(t, err) + require.False(t, errors.Is(err, context.Canceled)) + require.False(t, errors.Is(err, context.DeadlineExceeded)) +} + +func testClientConfig() *ssh.ClientConfig { + return &ssh.ClientConfig{ + User: "test", + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + } +} + +// dialSilentServer returns a client conn to a server that accepts and never +// sends anything, so the SSH handshake blocks until the context is done. +func dialSilentServer(t *testing.T) net.Conn { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = listener.Close() }) + + done := make(chan struct{}) + t.Cleanup(func() { close(done) }) + + go func() { + c, err := listener.Accept() + if err != nil { + return + } + defer func() { _ = c.Close() }() + <-done + }() + + conn, err := net.Dial("tcp", listener.Addr().String()) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + return conn +} diff --git a/client/ssh/proxy/proxy.go b/client/ssh/proxy/proxy.go index 73b50122c..070515b57 100644 --- a/client/ssh/proxy/proxy.go +++ b/client/ssh/proxy/proxy.go @@ -9,7 +9,6 @@ import ( "net" "os" "strconv" - "strings" "sync" "time" @@ -17,8 +16,8 @@ import ( log "github.com/sirupsen/logrus" cryptossh "golang.org/x/crypto/ssh" "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" + "github.com/netbirdio/netbird/client/internal/daemonaddr" "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/proto" nbssh "github.com/netbirdio/netbird/client/ssh" @@ -55,8 +54,8 @@ type SSHProxy struct { } func New(daemonAddr, targetHost string, targetPort int, stderr io.Writer, browserOpener func(string) error) (*SSHProxy, error) { - grpcAddr := strings.TrimPrefix(daemonAddr, "tcp://") - grpcConn, err := grpc.NewClient(grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + target, opts := daemonaddr.DialTarget(daemonAddr) + grpcConn, err := grpc.NewClient(target, opts...) if err != nil { return nil, fmt.Errorf("connect to daemon: %w", err) } @@ -611,13 +610,10 @@ func (p *SSHProxy) dialBackend(ctx context.Context, addr, user, jwtToken string) return nil, fmt.Errorf("connect to server: %w", err) } - clientConn, chans, reqs, err := cryptossh.NewClientConn(conn, addr, config) - if err != nil { - _ = conn.Close() - return nil, fmt.Errorf("SSH handshake: %w", err) - } + handshakeCtx, cancel := context.WithTimeout(ctx, sshHandshakeTimeout) + defer cancel() - return cryptossh.NewClient(clientConn, chans, reqs), nil + return nbssh.Handshake(handshakeCtx, conn, addr, config) } func (p *SSHProxy) verifyHostKey(hostname string, remote net.Addr, key cryptossh.PublicKey) error { diff --git a/client/ssh/server/command_execution.go b/client/ssh/server/command_execution.go index b0a85fe4b..c8b3240d0 100644 --- a/client/ssh/server/command_execution.go +++ b/client/ssh/server/command_execution.go @@ -75,8 +75,8 @@ func (s *Server) createCommand(logger *log.Entry, privilegeResult PrivilegeCheck } // Try su first for system integration (PAM/audit) when privileged - cmd, err := s.createSuCommand(logger, session, localUser, hasPty) - if err != nil || privilegeResult.UsedFallback { + cmd, err := s.createSuCommand(logger, session, localUser, hasPty) //nolint:staticcheck + if err != nil || privilegeResult.UsedFallback { //nolint:staticcheck // always errors on platforms without su logger.Debugf("su command failed, falling back to executor: %v", err) cmd, cleanup, err := s.createExecutorCommand(logger, session, localUser, hasPty) if err != nil { diff --git a/client/ssh/server/command_execution_windows.go b/client/ssh/server/command_execution_windows.go index e1ba777f6..feb8daa26 100644 --- a/client/ssh/server/command_execution_windows.go +++ b/client/ssh/server/command_execution_windows.go @@ -243,7 +243,7 @@ func (s *Server) setUserEnvironmentVariables(envMap map[string]string, userProfi // prepareCommandEnv prepares environment variables for command execution on Windows func (s *Server) prepareCommandEnv(logger *log.Entry, localUser *user.User, session ssh.Session) []string { - username, domain := s.parseUsername(localUser.Username) + username, domain := parseUsername(localUser.Username) userEnv, err := s.getUserEnvironment(logger, username, domain) if err != nil { log.Debugf("failed to get user environment for %s\\%s, using fallback: %v", domain, username, err) @@ -383,7 +383,7 @@ func (s *Server) executeCommandWithPty(logger *log.Entry, session ssh.Session, _ return false } - username, domain := s.parseUsername(localUser.Username) + username, domain := parseUsername(localUser.Username) shell := getUserShell(localUser.Uid) req := PtyExecutionRequest{ diff --git a/client/ssh/server/getent_cgo_unix.go b/client/ssh/server/getent_cgo_unix.go deleted file mode 100644 index 4afbfc627..000000000 --- a/client/ssh/server/getent_cgo_unix.go +++ /dev/null @@ -1,24 +0,0 @@ -//go:build cgo && !osusergo && !windows - -package server - -import "os/user" - -// lookupWithGetent with CGO delegates directly to os/user.Lookup. -// When CGO is enabled, os/user uses libc (getpwnam_r) which goes through -// the NSS stack natively. If it fails, the user truly doesn't exist and -// getent would also fail. -func lookupWithGetent(username string) (*user.User, error) { - return user.Lookup(username) -} - -// currentUserWithGetent with CGO delegates directly to os/user.Current. -func currentUserWithGetent() (*user.User, error) { - return user.Current() -} - -// groupIdsWithFallback with CGO delegates directly to user.GroupIds. -// libc's getgrouplist handles NSS groups natively. -func groupIdsWithFallback(u *user.User) ([]string, error) { - return u.GroupIds() -} diff --git a/client/ssh/server/getent_nocgo_unix.go b/client/ssh/server/getent_nocgo_unix.go deleted file mode 100644 index 314daae4c..000000000 --- a/client/ssh/server/getent_nocgo_unix.go +++ /dev/null @@ -1,74 +0,0 @@ -//go:build (!cgo || osusergo) && !windows - -package server - -import ( - "os" - "os/user" - "strconv" - - log "github.com/sirupsen/logrus" -) - -// lookupWithGetent looks up a user by name, falling back to getent if os/user fails. -// Without CGO, os/user only reads /etc/passwd and misses NSS-provided users. -// getent goes through the host's NSS stack. -func lookupWithGetent(username string) (*user.User, error) { - u, err := user.Lookup(username) - if err == nil { - return u, nil - } - - stdErr := err - log.Debugf("os/user.Lookup(%q) failed, trying getent: %v", username, err) - - u, _, getentErr := runGetent(username) - if getentErr != nil { - log.Debugf("getent fallback for %q also failed: %v", username, getentErr) - return nil, stdErr - } - - return u, nil -} - -// currentUserWithGetent gets the current user, falling back to getent if os/user fails. -func currentUserWithGetent() (*user.User, error) { - u, err := user.Current() - if err == nil { - return u, nil - } - - stdErr := err - uid := strconv.Itoa(os.Getuid()) - log.Debugf("os/user.Current() failed, trying getent with UID %s: %v", uid, err) - - u, _, getentErr := runGetent(uid) - if getentErr != nil { - return nil, stdErr - } - - return u, nil -} - -// groupIdsWithFallback gets group IDs for a user via the id command first, -// falling back to user.GroupIds(). -// NOTE: unlike lookupWithGetent/currentUserWithGetent which try stdlib first, -// this intentionally tries `id -G` first because without CGO, user.GroupIds() -// only reads /etc/group and silently returns incomplete results for NSS users -// (no error, just missing groups). The id command goes through NSS and returns -// the full set. -func groupIdsWithFallback(u *user.User) ([]string, error) { - ids, err := runIdGroups(u.Username) - if err == nil { - return ids, nil - } - - log.Debugf("id -G %q failed, falling back to user.GroupIds(): %v", u.Username, err) - - ids, stdErr := u.GroupIds() - if stdErr != nil { - return nil, stdErr - } - - return ids, nil -} diff --git a/client/ssh/server/getent_unix.go b/client/ssh/server/getent_unix.go deleted file mode 100644 index 18edb2fdf..000000000 --- a/client/ssh/server/getent_unix.go +++ /dev/null @@ -1,122 +0,0 @@ -//go:build !windows - -package server - -import ( - "context" - "fmt" - "os/exec" - "os/user" - "runtime" - "strings" - "time" -) - -const getentTimeout = 5 * time.Second - -// getShellFromGetent gets a user's login shell via getent by UID. -// This is needed even with CGO because getShellFromPasswd reads /etc/passwd -// directly and won't find NSS-provided users there. -func getShellFromGetent(userID string) string { - _, shell, err := runGetent(userID) - if err != nil { - return "" - } - return shell -} - -// runGetent executes `getent passwd ` and returns the user and login shell. -func runGetent(query string) (*user.User, string, error) { - if !validateGetentInput(query) { - return nil, "", fmt.Errorf("invalid getent input: %q", query) - } - - ctx, cancel := context.WithTimeout(context.Background(), getentTimeout) - defer cancel() - - out, err := exec.CommandContext(ctx, "getent", "passwd", query).Output() - if err != nil { - return nil, "", fmt.Errorf("getent passwd %s: %w", query, err) - } - - return parseGetentPasswd(string(out)) -} - -// parseGetentPasswd parses getent passwd output: "name:x:uid:gid:gecos:home:shell" -func parseGetentPasswd(output string) (*user.User, string, error) { - fields := strings.SplitN(strings.TrimSpace(output), ":", 8) - if len(fields) < 6 { - return nil, "", fmt.Errorf("unexpected getent output (need 6+ fields): %q", output) - } - - if fields[0] == "" || fields[2] == "" || fields[3] == "" { - return nil, "", fmt.Errorf("missing required fields in getent output: %q", output) - } - - var shell string - if len(fields) >= 7 { - shell = fields[6] - } - - return &user.User{ - Username: fields[0], - Uid: fields[2], - Gid: fields[3], - Name: fields[4], - HomeDir: fields[5], - }, shell, nil -} - -// validateGetentInput checks that the input is safe to pass to getent or id. -// Allows POSIX usernames, numeric UIDs, and common NSS extensions -// (@ for Kerberos, $ for Samba, + for NIS compat). -func validateGetentInput(input string) bool { - maxLen := 32 - if runtime.GOOS == "linux" { - maxLen = 256 - } - - if len(input) == 0 || len(input) > maxLen { - return false - } - - for _, r := range input { - if isAllowedGetentChar(r) { - continue - } - return false - } - return true -} - -func isAllowedGetentChar(r rune) bool { - if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' { - return true - } - switch r { - case '.', '_', '-', '@', '+', '$': - return true - } - return false -} - -// runIdGroups runs `id -G ` and returns the space-separated group IDs. -func runIdGroups(username string) ([]string, error) { - if !validateGetentInput(username) { - return nil, fmt.Errorf("invalid username for id command: %q", username) - } - - ctx, cancel := context.WithTimeout(context.Background(), getentTimeout) - defer cancel() - - out, err := exec.CommandContext(ctx, "id", "-G", username).Output() - if err != nil { - return nil, fmt.Errorf("id -G %s: %w", username, err) - } - - trimmed := strings.TrimSpace(string(out)) - if trimmed == "" { - return nil, fmt.Errorf("id -G %s: empty output", username) - } - return strings.Fields(trimmed), nil -} diff --git a/client/ssh/server/getent_windows.go b/client/ssh/server/getent_windows.go deleted file mode 100644 index 3e76b3e8e..000000000 --- a/client/ssh/server/getent_windows.go +++ /dev/null @@ -1,26 +0,0 @@ -//go:build windows - -package server - -import "os/user" - -// lookupWithGetent on Windows just delegates to os/user.Lookup. -// Windows does not use NSS/getent; its user lookup works without CGO. -func lookupWithGetent(username string) (*user.User, error) { - return user.Lookup(username) -} - -// currentUserWithGetent on Windows just delegates to os/user.Current. -func currentUserWithGetent() (*user.User, error) { - return user.Current() -} - -// getShellFromGetent is a no-op on Windows; shell resolution uses PowerShell detection. -func getShellFromGetent(_ string) string { - return "" -} - -// groupIdsWithFallback on Windows just delegates to u.GroupIds(). -func groupIdsWithFallback(u *user.User) ([]string, error) { - return u.GroupIds() -} diff --git a/client/ssh/server/port_forwarding.go b/client/ssh/server/port_forwarding.go index a47fdb48a..81d3b9173 100644 --- a/client/ssh/server/port_forwarding.go +++ b/client/ssh/server/port_forwarding.go @@ -133,7 +133,12 @@ func (s *Server) checkPrivilegedPortAccess(forwardType string, port uint32, resu return nil } - if result.User != nil && isPrivilegedUsername(result.User.Username) { + // Only uid 0 may bind below the threshold, which is the kernel's own rule and + // is asked directly rather than through isPrivilegedOrUnknown: that helper + // reports an account it cannot evaluate as privileged, which is safe for a + // refusal and unsafe for a grant such as this one. Windows has returned + // above, so Uid here is a Unix uid and never a SID. + if result.User != nil && result.User.Uid == "0" { return nil } diff --git a/client/ssh/server/privileges_other.go b/client/ssh/server/privileges_other.go new file mode 100644 index 000000000..89440dea8 --- /dev/null +++ b/client/ssh/server/privileges_other.go @@ -0,0 +1,16 @@ +//go:build !windows + +package server + +// isProcessElevated is only meaningful on Windows; other platforms use the +// effective UID check in isCurrentProcessPrivileged. +func isProcessElevated() bool { + return false +} + +// isWindowsAccountPrivilegedOrUnknown is only reachable on Windows. Report +// privileged on other platforms so a caller refusing privileged accounts fails +// closed. +func isWindowsAccountPrivilegedOrUnknown(string) bool { + return true +} diff --git a/client/ssh/server/privileges_windows.go b/client/ssh/server/privileges_windows.go new file mode 100644 index 000000000..41ea00fd7 --- /dev/null +++ b/client/ssh/server/privileges_windows.go @@ -0,0 +1,228 @@ +//go:build windows + +package server + +import ( + "fmt" + "strings" + "unsafe" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" +) + +var ( + netapi32 = windows.NewLazySystemDLL("netapi32.dll") + procNetUserGetLocalGroups = netapi32.NewProc("NetUserGetLocalGroups") +) + +const ( + // lgIncludeIndirect makes NetUserGetLocalGroups also return local groups + // the user belongs to through a global group. + lgIncludeIndirect = 0x1 + maxPreferredLength = 0xFFFFFFFF +) + +// localGroupUsersInfo0 mirrors LOCALGROUP_USERS_INFO_0. +type localGroupUsersInfo0 struct { + name *uint16 +} + +// isProcessElevated reports whether the current process token is elevated +// (TokenElevation): true for elevated administrators, the built-in +// Administrator, administrators with UAC disabled, and SYSTEM; false for +// standard users and administrators running with a UAC-filtered token. +func isProcessElevated() bool { + return windows.GetCurrentProcessToken().IsElevated() +} + +// isWindowsAccountPrivilegedOrUnknown reports whether the account is privileged +// on this machine: a well-known service account, a built-in Administrator +// (RID 500), or a member of the local Administrators group, directly or through +// nested groups. +// +// An account whose privilege cannot be determined counts as privileged, which +// is why the name says "or unknown". That is fail-closed for a caller that +// refuses privileged accounts, and fail-open for a caller that grants something +// to them, so only the former may use this. +func isWindowsAccountPrivilegedOrUnknown(username string) bool { + sid, _, _, err := windows.LookupSID("", username) + if err != nil { + log.Warnf("privilege check: SID lookup for %q failed, treating as privileged: %v", username, err) + return true + } + + if isPrivilegedUserSID(sid) { + return true + } + + member, err := isLocalAdminsMember(username) + if err != nil { + log.Warnf("privilege check: cannot determine Administrators membership for %q, treating as privileged: %v", username, err) + return true + } + return member +} + +// isPrivilegedUserSID reports whether the SID itself identifies a privileged +// principal, without consulting group membership. +func isPrivilegedUserSID(sid *windows.SID) bool { + wellKnown := []windows.WELL_KNOWN_SID_TYPE{ + windows.WinLocalSystemSid, + windows.WinLocalServiceSid, + windows.WinNetworkServiceSid, + windows.WinBuiltinAdministratorsSid, + } + for _, sidType := range wellKnown { + if sid.IsWellKnown(sidType) { + return true + } + } + return isBuiltinAdministratorSID(sid) +} + +// isBuiltinAdministratorSID reports whether the SID is a machine or domain +// built-in Administrator account (S-1-5-21-...-500). RID 500 is reserved for +// that account; it can be renamed but cannot be removed from the +// Administrators group. +func isBuiltinAdministratorSID(sid *windows.SID) bool { + if sid.IdentifierAuthority() != windows.SECURITY_NT_AUTHORITY { + return false + } + count := sid.SubAuthorityCount() + if count < 2 || sid.SubAuthority(0) != 21 { + return false + } + return sid.SubAuthority(uint32(count-1)) == 500 +} + +// isLocalAdminsMember reports whether the account is a member of the local +// Administrators group. +// +// Local accounts are checked against the local SAM, which is authoritative for +// them and, unlike a token, cannot under-report: UAC filters the tokens of +// local administrators, and a filtered token carries Administrators as +// deny-only, which a membership check on the token would read as "not a +// member". Domain accounts are exempt from that filtering, so for them an S4U +// token is preferred because its group list is LSA's transitive expansion and +// therefore covers nested and universal groups plus the machine's own local +// groups. NetUserGetLocalGroups expands only one global-group hop but needs no +// logon, so it serves as the fallback when no token can be obtained. +func isLocalAdminsMember(username string) (bool, error) { + adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + if err != nil { + return false, fmt.Errorf("create Administrators SID: %w", err) + } + + account, domain := parseUsername(username) + if NewPrivilegeDropper().isLocalUser(domain) { + return localGroupsContainSID(account, adminSid) + } + + member, s4uErr := s4uTokenIsMember(account, domain, adminSid) + if s4uErr == nil { + return member, nil + } + log.Debugf("privilege check: S4U membership check for %q failed, falling back to local group enumeration: %v", username, s4uErr) + + member, err = localGroupsContainSID(buildUserCpn(account, domain), adminSid) + if err != nil { + return false, fmt.Errorf("S4U check: %w; local group enumeration: %w", s4uErr, err) + } + return member, nil +} + +// s4uTokenIsMember obtains an S4U token for the account and checks whether the +// given SID is enabled in it. +func s4uTokenIsMember(account, domain string, sid *windows.SID) (bool, error) { + token, err := generateS4UUserToken(log.NewEntry(log.StandardLogger()), account, domain) + if err != nil { + return false, err + } + defer func() { + if err := windows.CloseHandle(token); err != nil { + log.Debugf("close S4U token: %v", err) + } + }() + return windows.Token(token).IsMember(sid) +} + +// localGroupsContainSID reports whether the wanted group is among the local +// groups the account belongs to, directly or through a global group. +// +// The wanted SID is resolved to its group name once and compared against the +// enumerated names. Well-known SIDs resolve from a static table, so that lookup +// needs no domain controller, and it keeps the comparison correct for a renamed +// or localized group because both sides then carry the new name. Resolving each +// enumerated name back to a SID instead would add a lookup per group that can +// block until it times out while a domain controller is unreachable, and cannot +// change the outcome: the names enumerated here are local groups of this +// machine, whose names are unique, so a name match identifies the group. +// +// A failure to resolve the wanted SID is returned rather than reported as +// "not a member", so a privilege check built on this fails closed. +func localGroupsContainSID(username string, want *windows.SID) (bool, error) { + wantName, _, _, err := want.LookupAccount("") + if err != nil { + return false, fmt.Errorf("resolve group SID %s to a name: %w", want, err) + } + + groups, err := netUserGetLocalGroups(username) + if err != nil { + return false, err + } + + for _, group := range groups { + if strings.EqualFold(group, wantName) { + return true, nil + } + } + return false, nil +} + +// netUserGetLocalGroups returns the names of the local groups the account is a +// member of, including indirect membership through global groups. +func netUserGetLocalGroups(username string) ([]string, error) { + name16, err := windows.UTF16PtrFromString(username) + if err != nil { + return nil, fmt.Errorf("convert username: %w", err) + } + + var buf *byte + var entriesRead, totalEntries uint32 + status, _, _ := procNetUserGetLocalGroups.Call( + 0, // local server + uintptr(unsafe.Pointer(name16)), + 0, // level 0: LOCALGROUP_USERS_INFO_0 + lgIncludeIndirect, + uintptr(unsafe.Pointer(&buf)), + maxPreferredLength, + uintptr(unsafe.Pointer(&entriesRead)), + uintptr(unsafe.Pointer(&totalEntries)), + ) + if status != 0 { + return nil, fmt.Errorf("NetUserGetLocalGroups for %q: status %d", username, status) + } + if buf == nil { + return nil, nil + } + defer func() { + if err := windows.NetApiBufferFree(buf); err != nil { + log.Debugf("free NetApi buffer: %v", err) + } + }() + + // MAX_PREFERRED_LENGTH makes the API allocate as much as it needs, so a + // short read is not expected. Report it rather than silently returning a + // subset of the account's groups. + if entriesRead != totalEntries { + return nil, fmt.Errorf("NetUserGetLocalGroups for %q returned %d of %d groups", username, entriesRead, totalEntries) + } + + entries := unsafe.Slice((*localGroupUsersInfo0)(unsafe.Pointer(buf)), entriesRead) + groups := make([]string, 0, entriesRead) + for _, entry := range entries { + groups = append(groups, windows.UTF16PtrToString(entry.name)) + } + return groups, nil +} diff --git a/client/ssh/server/privileges_windows_test.go b/client/ssh/server/privileges_windows_test.go new file mode 100644 index 000000000..983fccdf7 --- /dev/null +++ b/client/ssh/server/privileges_windows_test.go @@ -0,0 +1,293 @@ +//go:build windows + +package server + +import ( + "os/user" + "testing" + "unsafe" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +// filterNormalAccount limits NetUserEnum to normal user accounts. +const filterNormalAccount = 0x2 + +// TOKEN_ELEVATION_TYPE values. +const ( + tokenElevationTypeDefault = 1 + tokenElevationTypeFull = 2 + tokenElevationTypeLimited = 3 +) + +// tokenElevationType reads TokenElevationType from a token. +func tokenElevationType(token windows.Token) (uint32, error) { + var elevationType, returnedLen uint32 + err := windows.GetTokenInformation(token, windows.TokenElevationType, + (*byte)(unsafe.Pointer(&elevationType)), uint32(unsafe.Sizeof(elevationType)), &returnedLen) + if err != nil { + return 0, err + } + return elevationType, nil +} + +// userInfo0 mirrors USER_INFO_0. +type userInfo0 struct { + name *uint16 +} + +func mustParseSID(t *testing.T, s string) *windows.SID { + t.Helper() + sid, err := windows.StringToSid(s) + require.NoError(t, err, "parse SID %s", s) + return sid +} + +// localAccountNames returns the names of the local user accounts. +func localAccountNames(t *testing.T) []string { + t.Helper() + + var buf *byte + var entriesRead, totalEntries, resume uint32 + err := windows.NetUserEnum(nil, 0, filterNormalAccount, &buf, maxPreferredLength, + &entriesRead, &totalEntries, &resume) + require.NoError(t, err, "enumerate local users") + t.Cleanup(func() { + require.NoError(t, windows.NetApiBufferFree(buf), "free NetApi buffer") + }) + + entries := unsafe.Slice((*userInfo0)(unsafe.Pointer(buf)), entriesRead) + names := make([]string, 0, entriesRead) + for _, entry := range entries { + names = append(names, windows.UTF16PtrToString(entry.name)) + } + return names +} + +// localAccountNameByRID returns the name of the local account carrying the +// given RID. Accounts such as Administrator and Guest can be renamed and are +// localized, so tests must not name them literally. +func localAccountNameByRID(t *testing.T, rid uint32) string { + t.Helper() + + for _, name := range localAccountNames(t) { + sid, _, _, err := windows.LookupSID("", name) + if err != nil { + continue + } + if sid.IdentifierAuthority() != windows.SECURITY_NT_AUTHORITY { + continue + } + count := sid.SubAuthorityCount() + if count < 2 || sid.SubAuthority(0) != 21 { + continue + } + if sid.SubAuthority(uint32(count-1)) == rid { + return name + } + } + + t.Fatalf("no local account with RID %d", rid) + return "" +} + +// wellKnownAccountName resolves a well-known SID to the qualified account name +// the local system uses for it, which is localized. +func wellKnownAccountName(t *testing.T, sidType windows.WELL_KNOWN_SID_TYPE) string { + t.Helper() + + sid, err := windows.CreateWellKnownSid(sidType) + require.NoError(t, err, "create well-known SID") + name, domain, _, err := sid.LookupAccount("") + require.NoError(t, err, "resolve %s to an account name", sid) + if domain == "" { + return name + } + return domain + `\` + name +} + +func TestIsBuiltinAdministratorSID(t *testing.T) { + tests := []struct { + name string + sid string + want bool + }{ + {"machine_administrator", "S-1-5-21-1111111111-2222222222-3333333333-500", true}, + {"domain_administrator", "S-1-5-21-3390233681-4087452608-412898826-500", true}, + {"regular_user", "S-1-5-21-1111111111-2222222222-3333333333-1001", false}, + {"guest_account", "S-1-5-21-1111111111-2222222222-3333333333-501", false}, + {"domain_admins_group", "S-1-5-21-1111111111-2222222222-3333333333-512", false}, + {"system", "S-1-5-18", false}, + {"administrators_group", "S-1-5-32-544", false}, + {"non_nt_authority", "S-1-1-0", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isBuiltinAdministratorSID(mustParseSID(t, tt.sid)) + assert.Equal(t, tt.want, result, "RID 500 detection for %s", tt.sid) + }) + } +} + +func TestIsPrivilegedUserSID(t *testing.T) { + tests := []struct { + name string + sid string + want bool + }{ + {"local_system", "S-1-5-18", true}, + {"local_service", "S-1-5-19", true}, + {"network_service", "S-1-5-20", true}, + {"administrators_group", "S-1-5-32-544", true}, + {"builtin_administrator", "S-1-5-21-1111111111-2222222222-3333333333-500", true}, + {"regular_user", "S-1-5-21-1111111111-2222222222-3333333333-1001", false}, + {"users_group", "S-1-5-32-545", false}, + {"everyone", "S-1-1-0", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isPrivilegedUserSID(mustParseSID(t, tt.sid)) + assert.Equal(t, tt.want, result, "SID privilege classification for %s", tt.sid) + }) + } +} + +func TestIsWindowsAccountPrivilegedOrUnknown(t *testing.T) { + tests := []struct { + name string + username string + want bool + }{ + {"system", wellKnownAccountName(t, windows.WinLocalSystemSid), true}, + {"local_service", wellKnownAccountName(t, windows.WinLocalServiceSid), true}, + {"network_service", wellKnownAccountName(t, windows.WinNetworkServiceSid), true}, + {"administrators_group", wellKnownAccountName(t, windows.WinBuiltinAdministratorsSid), true}, + // The built-in Administrator (RID 500) and Guest (RID 501) accounts + // exist on every Windows installation, though they may be disabled. + {"builtin_administrator", localAccountNameByRID(t, 500), true}, + {"guest", localAccountNameByRID(t, 501), false}, + // Unresolvable accounts fail closed. + {"nonexistent_user", "netbird-no-such-user", true}, + {"empty_username", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isWindowsAccountPrivilegedOrUnknown(tt.username) + assert.Equal(t, tt.want, result, "account privilege classification for %q", tt.username) + }) + } +} + +func TestIsProcessElevated(t *testing.T) { + elevated := isProcessElevated() + + // TokenElevationType is a second, independent view of the same token: + // Full means elevated and Limited means a filtered administrator, while + // Default covers both a standard user and an administrator with no linked + // token (UAC off, the built-in Administrator, SYSTEM), so it implies nothing. + elevationType, err := tokenElevationType(windows.GetCurrentProcessToken()) + require.NoError(t, err, "read token elevation type") + + adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + require.NoError(t, err, "create Administrators SID") + + // Token(0) makes CheckTokenMembership evaluate the caller's own token. It + // counts only enabled SIDs, so a filtered administrator reports false here. + member, err := windows.Token(0).IsMember(adminSid) + require.NoError(t, err, "check own Administrators membership") + + t.Logf("elevated=%v elevationType=%d memberOfAdministrators=%v", elevated, elevationType, member) + + switch elevationType { + case tokenElevationTypeFull: + assert.True(t, elevated, "a token of elevation type Full must report elevated") + case tokenElevationTypeLimited: + assert.False(t, elevated, "a filtered administrator token must not report elevated") + } + + // Administrators enabled in the token means the token wields administrative + // rights, which is what elevation reports. + if member { + assert.True(t, elevated, "token with enabled Administrators membership must report elevated") + } +} + +// TestS4UMembershipAgreesWithLocalGroups exercises the S4U token path used +// for domain accounts. S4U logons need the TCB privilege, so the test runs +// only as SYSTEM (which is how CI executes the suite). For local accounts the +// token's Administrators membership must agree with the SAM enumeration. +func TestS4UMembershipAgreesWithLocalGroups(t *testing.T) { + system, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + require.NoError(t, err, "create SYSTEM SID") + current, err := user.Current() + require.NoError(t, err, "get current user") + if current.Uid != system.String() { + t.Skipf("S4U logon requires SYSTEM (running as %s)", current.Username) + } + + adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + require.NoError(t, err, "create Administrators SID") + + checked := 0 + for _, name := range localAccountNames(t) { + viaToken, err := s4uTokenIsMember(name, ".", adminSid) + if err != nil { + // Disabled or logon-restricted accounts cannot get an S4U logon. + t.Logf("skipping %s: %v", name, err) + continue + } + viaSAM, err := localGroupsContainSID(name, adminSid) + require.NoError(t, err, "enumerate local groups for %s", name) + + assert.Equal(t, viaSAM, viaToken, "S4U token and SAM enumeration must agree on Administrators membership for %s", name) + checked++ + } + // Ineligible accounts are skipped, so without this the test could report + // success while comparing nothing at all. + require.Positive(t, checked, "no local account completed an S4U logon, so nothing was compared") + t.Logf("checked %d local accounts via S4U", checked) +} + +// TestLocalGroupsContainSID_Administrator checks the positive case against the +// built-in Administrator, a member of Administrators on every installation. +func TestLocalGroupsContainSID_Administrator(t *testing.T) { + adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + require.NoError(t, err, "create Administrators SID") + + administrator := localAccountNameByRID(t, 500) + member, err := localGroupsContainSID(administrator, adminSid) + require.NoError(t, err, "enumerate local groups for %s", administrator) + assert.True(t, member, "%s is a member of the Administrators group", administrator) +} + +// TestLocalGroupsContainSID_UnresolvableGroupFailsClosed covers a wanted SID +// that resolves to no group: the error must surface rather than being reported +// as "not a member", so the privilege check treats the account as privileged. +func TestLocalGroupsContainSID_UnresolvableGroupFailsClosed(t *testing.T) { + unknown := mustParseSID(t, "S-1-5-21-1111111111-2222222222-3333333333-4444") + + _, err := localGroupsContainSID(localAccountNameByRID(t, 500), unknown) + require.Error(t, err, "must report an error when the wanted group cannot be identified") +} + +func TestLocalGroupsContainSID_Guest(t *testing.T) { + guestsSid, err := windows.CreateWellKnownSid(windows.WinBuiltinGuestsSid) + require.NoError(t, err, "create Guests SID") + adminsSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + require.NoError(t, err, "create Administrators SID") + + guest := localAccountNameByRID(t, 501) + + inGuests, err := localGroupsContainSID(guest, guestsSid) + require.NoError(t, err, "enumerate local groups for %s", guest) + assert.True(t, inGuests, "%s is a member of the Guests group", guest) + + inAdmins, err := localGroupsContainSID(guest, adminsSid) + require.NoError(t, err, "enumerate local groups for %s", guest) + assert.False(t, inAdmins, "%s is not a member of the Administrators group", guest) +} diff --git a/client/ssh/server/server.go b/client/ssh/server/server.go index 6735e0f3b..b32da796e 100644 --- a/client/ssh/server/server.go +++ b/client/ssh/server/server.go @@ -197,6 +197,12 @@ type Config struct { // HostKey is the SSH server host key in PEM format HostKeyPEM []byte + + // Auth is the fine-grained authorization to open with. Nil starts with an + // empty authorizer, which authorizes nobody until UpdateSSHAuth is called. + // Setting it here rather than afterwards means the server never accepts a + // login before it knows who is allowed. + Auth *sshauth.Config } // SessionInfo contains information about an active SSH session @@ -220,7 +226,11 @@ func New(config *Config) *Server { connections: make(map[connKey]*connState), jwtEnabled: config.JWT != nil, jwtConfig: config.JWT, - authorizer: sshauth.NewAuthorizer(), // Initialize with empty config + authorizer: sshauth.NewAuthorizer(), + } + + if config.Auth != nil { + s.authorizer.Update(config.Auth) } return s @@ -461,6 +471,27 @@ func (s *Server) UpdateSSHAuth(config *sshauth.Config) { s.authorizer.Update(config) } +// JWTConfig returns the JWT authentication this server was built with, or nil +// when JWT authentication is disabled. +func (s *Server) JWTConfig() *JWTConfig { + s.mu.RLock() + defer s.mu.RUnlock() + return s.jwtConfig +} + +// AuthConfig returns the fine-grained authorization currently in force, or nil +// when the server has no authorizer. +func (s *Server) AuthConfig() *sshauth.Config { + s.mu.RLock() + authorizer := s.authorizer + s.mu.RUnlock() + + if authorizer == nil { + return nil + } + return authorizer.Config() +} + // ensureJWTValidator initializes the JWT validator and extractor if not already initialized func (s *Server) ensureJWTValidator() error { s.mu.RLock() diff --git a/client/ssh/server/server_config_test.go b/client/ssh/server/server_config_test.go index f70e29963..983bd7a43 100644 --- a/client/ssh/server/server_config_test.go +++ b/client/ssh/server/server_config_test.go @@ -239,6 +239,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) { forwardType string port uint32 username string + uid string expectError bool errorMsg string skipOnWindows bool @@ -248,6 +249,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) { forwardType: "remote", port: 80, username: "testuser", + uid: "1000", expectError: true, errorMsg: "cannot bind to privileged port", skipOnWindows: true, @@ -257,6 +259,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) { forwardType: "tcpip-forward", port: 443, username: "testuser", + uid: "1000", expectError: true, errorMsg: "cannot bind to privileged port", skipOnWindows: true, @@ -266,6 +269,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) { forwardType: "remote", port: 8080, username: "testuser", + uid: "1000", expectError: false, }, { @@ -273,6 +277,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) { forwardType: "remote", port: 0, username: "testuser", + uid: "1000", expectError: false, }, { @@ -280,13 +285,35 @@ func TestServer_PrivilegedPortAccess(t *testing.T) { forwardType: "remote", port: 22, username: "root", + uid: "0", expectError: false, }, + { + // Only uid 0 is privileged, whatever the account is called. + name: "uid 0 under another name may bind a privileged port", + forwardType: "remote", + port: 22, + username: "toor", + uid: "0", + expectError: false, + skipOnWindows: true, + }, + { + name: "account named root without uid 0 may not", + forwardType: "remote", + port: 22, + username: "root", + uid: "1000", + expectError: true, + errorMsg: "cannot bind to privileged port", + skipOnWindows: true, + }, { name: "local forward privileged port allowed for non-root", forwardType: "local", port: 80, username: "testuser", + uid: "1000", expectError: false, }, } @@ -299,7 +326,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) { result := PrivilegeCheckResult{ Allowed: true, - User: &user.User{Username: tt.username}, + User: &user.User{Username: tt.username, Uid: tt.uid}, } err := server.checkPrivilegedPortAccess(tt.forwardType, tt.port, result) @@ -420,6 +447,13 @@ func TestServer_PortConflictHandling(t *testing.T) { func TestServer_IsPrivilegedUser(t *testing.T) { + // Windows classification depends on account SIDs and group membership, and + // the accounts involved carry localized, renameable names. It is covered by + // TestIsWindowsAccountPrivileged, which resolves them from well-known SIDs. + if runtime.GOOS == "windows" { + t.Skip("covered by TestIsWindowsAccountPrivileged") + } + tests := []struct { username string expected bool @@ -440,44 +474,16 @@ func TestServer_IsPrivilegedUser(t *testing.T) { expected: false, description: "empty username should not be privileged", }, - } - - // Add Windows-specific tests - if runtime.GOOS == "windows" { - tests = append(tests, []struct { - username string - expected bool - description string - }{ - { - username: "Administrator", - expected: true, - description: "Administrator should be considered privileged on Windows", - }, - { - username: "administrator", - expected: true, - description: "administrator should be considered privileged on Windows (case insensitive)", - }, - }...) - } else { - // On non-Windows systems, Administrator should not be privileged - tests = append(tests, []struct { - username string - expected bool - description string - }{ - { - username: "Administrator", - expected: false, - description: "Administrator should not be privileged on non-Windows systems", - }, - }...) + { + username: "Administrator", + expected: false, + description: "Administrator should not be privileged on non-Windows systems", + }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - result := isPrivilegedUsername(tt.username) + result := isPrivilegedOrUnknown(tt.username) assert.Equal(t, tt.expected, result, tt.description) }) } diff --git a/client/ssh/server/sftp_windows.go b/client/ssh/server/sftp_windows.go index dc532b9e7..25cd17298 100644 --- a/client/ssh/server/sftp_windows.go +++ b/client/ssh/server/sftp_windows.go @@ -17,7 +17,7 @@ import ( // createSftpCommand creates a Windows SFTP command with user switching. // The caller must close the returned token handle after starting the process. func (s *Server) createSftpCommand(targetUser *user.User, sess ssh.Session) (*exec.Cmd, windows.Token, error) { - username, domain := s.parseUsername(targetUser.Username) + username, domain := parseUsername(targetUser.Username) netbirdPath, err := os.Executable() if err != nil { diff --git a/client/ssh/server/shell.go b/client/ssh/server/shell.go index 1e8ff5e31..7b356b2a0 100644 --- a/client/ssh/server/shell.go +++ b/client/ssh/server/shell.go @@ -13,6 +13,8 @@ import ( "github.com/gliderlabs/ssh" log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/getent" ) const ( @@ -56,7 +58,11 @@ func getUnixUserShell(userID string) string { return shell } - if shell := getShellFromGetent(userID); shell != "" { + shell, err := getent.UserShell(userID) + if err != nil { + log.Debugf("look up the shell for uid %s through getent: %v", userID, err) + } + if shell != "" { return shell } diff --git a/client/ssh/server/shell_unix_test.go b/client/ssh/server/shell_unix_test.go new file mode 100644 index 000000000..c5e65e535 --- /dev/null +++ b/client/ssh/server/shell_unix_test.go @@ -0,0 +1,94 @@ +//go:build !windows + +package server + +import ( + "os/exec" + "os/user" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/getent" +) + +// TestGetShellFromPasswd_CurrentUser verifies that getShellFromPasswd correctly +// reads the current user's shell from /etc/passwd by comparing it against what +// getent reports (which goes through NSS). +func TestGetShellFromPasswd_CurrentUser(t *testing.T) { + current, err := user.Current() + require.NoError(t, err) + + shell := getShellFromPasswd(current.Uid) + if shell == "" { + t.Skip("current user not found in /etc/passwd (may be an NSS-only user)") + } + + assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell) + + if _, err := exec.LookPath("getent"); err == nil { + getentShell, getentErr := getent.UserShell(current.Uid) + if getentErr == nil && getentShell != "" { + assert.Equal(t, getentShell, shell, "shell from /etc/passwd should match getent") + } + } +} + +// TestGetShellFromPasswd_RootUser verifies that getShellFromPasswd can read +// root's shell from /etc/passwd. Root is guaranteed to be in /etc/passwd on +// any standard Unix system. +func TestGetShellFromPasswd_RootUser(t *testing.T) { + shell := getShellFromPasswd("0") + require.NotEmpty(t, shell, "root (UID 0) must be in /etc/passwd") + assert.True(t, shell[0] == '/', "root shell should be an absolute path, got %q", shell) +} + +// TestGetShellFromPasswd_NonexistentUID verifies that getShellFromPasswd +// returns empty for a UID that doesn't exist in /etc/passwd. +func TestGetShellFromPasswd_NonexistentUID(t *testing.T) { + shell := getShellFromPasswd("4294967294") + assert.Empty(t, shell, "nonexistent UID should return empty shell") +} + +// TestGetShellFromPasswd_MatchesGetentForKnownUsers reads /etc/passwd directly +// and cross-validates every entry against getent to ensure the two shell +// sources agree. +func TestGetShellFromPasswd_MatchesGetentForKnownUsers(t *testing.T) { + if _, err := exec.LookPath("getent"); err != nil { + t.Skip("getent not available") + } + + // Pick a few well-known system UIDs that are virtually always in /etc/passwd. + uids := []string{"0"} // root + + current, err := user.Current() + require.NoError(t, err) + uids = append(uids, current.Uid) + + for _, uid := range uids { + passwdShell := getShellFromPasswd(uid) + if passwdShell == "" { + continue + } + + getentShell, err := getent.UserShell(uid) + if err != nil { + continue + } + + assert.Equal(t, getentShell, passwdShell, "shell mismatch for UID %s", uid) + } +} + +// TestIntegration_ShellLookupChain tests the full shell resolution chain +// (getShellFromPasswd -> getent -> $SHELL -> default). +func TestIntegration_ShellLookupChain(t *testing.T) { + current, err := user.Current() + require.NoError(t, err) + + // getUserShell is the top-level function used by the SSH server. + shell := getUserShell(current.Uid) + require.NotEmpty(t, shell, "getUserShell must always return a shell") + assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell) +} diff --git a/client/ssh/server/test.go b/client/ssh/server/test.go index e2be0551c..7ca28f034 100644 --- a/client/ssh/server/test.go +++ b/client/ssh/server/test.go @@ -1,9 +1,9 @@ // This file is intentionally named test.go (not test_test.go) so the exported // StartTestServer helper is visible to the ssh/proxy and ssh/client external // test packages, not just this package's own tests. The //go:build !js tag -// keeps its "testing" import — and the whole testing/flag/regexp transitive -// chain it drags in — out of the wasm client, which links ssh/server through -// the engine but never runs Go tests under GOOS=js. +// keeps its "testing" import, along with the whole testing/flag/regexp +// transitive chain it drags in, out of the wasm client, which links +// ssh/server through the engine but never runs Go tests under GOOS=js. //go:build !js package server diff --git a/client/ssh/server/user_utils.go b/client/ssh/server/user_utils.go index bc2aa2d7d..f2f33b3d7 100644 --- a/client/ssh/server/user_utils.go +++ b/client/ssh/server/user_utils.go @@ -9,6 +9,8 @@ import ( "strings" log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/getent" ) var ( @@ -16,19 +18,17 @@ var ( ErrPrivilegedUserSwitch = errors.New("cannot switch to privileged user - current user lacks required privileges") ) -// isPlatformUnix returns true for Unix-like platforms (Linux, macOS, etc.) -func isPlatformUnix() bool { - return getCurrentOS() != "windows" -} - // Dependency injection variables for testing - allows mocking dynamic runtime checks var ( - getCurrentUser = currentUserWithGetent - lookupUser = lookupWithGetent + getCurrentUser = getent.CurrentUser + lookupUser = getent.LookupUser getCurrentOS = func() string { return runtime.GOOS } getIsProcessPrivileged = isCurrentProcessPrivileged getEuid = os.Geteuid + + getProcessElevated = isProcessElevated + getWindowsAccountPrivilegedOrUnknown = isWindowsAccountPrivilegedOrUnknown ) const ( @@ -65,6 +65,13 @@ type PrivilegeCheckResult struct { RequiresUserSwitching bool } +// privilegeCheckContext holds all context needed for privilege checking +type privilegeCheckContext struct { + currentUser *user.User + currentUserPrivileged bool + allowRoot bool +} + // CheckPrivileges performs comprehensive privilege checking for all SSH features. // This is the single source of truth for privilege decisions across the SSH server. func (s *Server) CheckPrivileges(req PrivilegeCheckRequest) PrivilegeCheckResult { @@ -75,7 +82,7 @@ func (s *Server) CheckPrivileges(req PrivilegeCheckRequest) PrivilegeCheckResult // Handle empty username case - but still check root access controls if req.RequestedUsername == "" { - if isPrivilegedUsername(context.currentUser.Username) && !context.allowRoot { + if isPrivilegedOrUnknown(context.currentUser.Username) && !context.allowRoot { return PrivilegeCheckResult{ Allowed: false, Error: &PrivilegedUserError{Username: context.currentUser.Username}, @@ -135,7 +142,7 @@ func (s *Server) checkUserRequest(ctx *privilegeCheckContext, req PrivilegeCheck needsUserSwitching := !isSameResolvedUser(resolvedUser, ctx.currentUser) - if isPrivilegedUsername(resolvedUser.Username) && !ctx.allowRoot { + if isPrivilegedOrUnknown(resolvedUser.Username) && !ctx.allowRoot { return PrivilegeCheckResult{ Allowed: false, Error: &PrivilegedUserError{Username: resolvedUser.Username}, @@ -175,6 +182,42 @@ func (s *Server) resolveRequestedUser(requestedUsername string) (*user.User, err return u, nil } +// SetAllowRootLogin configures root login access +func (s *Server) SetAllowRootLogin(allow bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.allowRootLogin = allow +} + +// userNameLookup performs user lookup with root login permission check +func (s *Server) userNameLookup(username string) (*user.User, error) { + result, err := s.userPrivilegeCheck(username) + if err != nil { + return nil, err + } + return result.User, nil +} + +// userPrivilegeCheck performs user lookup with full privilege check result +func (s *Server) userPrivilegeCheck(username string) (PrivilegeCheckResult, error) { + result := s.CheckPrivileges(PrivilegeCheckRequest{ + RequestedUsername: username, + FeatureSupportsUserSwitch: true, + FeatureName: FeatureSSHLogin, + }) + + if !result.Allowed { + return result, result.Error + } + + return result, nil +} + +// isPlatformUnix returns true for Unix-like platforms (Linux, macOS, etc.) +func isPlatformUnix() bool { + return getCurrentOS() != "windows" +} + // isSameResolvedUser compares two resolved user identities func isSameResolvedUser(user1, user2 *user.User) bool { if user1 == nil || user2 == nil { @@ -183,13 +226,6 @@ func isSameResolvedUser(user1, user2 *user.User) bool { return user1.Uid == user2.Uid } -// privilegeCheckContext holds all context needed for privilege checking -type privilegeCheckContext struct { - currentUser *user.User - currentUserPrivileged bool - allowRoot bool -} - // isSameUser checks if two usernames refer to the same user // SECURITY: This function must be conservative - it should only return true // when we're certain both usernames refer to the exact same user identity @@ -253,159 +289,30 @@ func isWindowsSameUser(requestedUsername, currentUsername string) bool { return strings.EqualFold(reqDomain, curDomain) } -// SetAllowRootLogin configures root login access -func (s *Server) SetAllowRootLogin(allow bool) { - s.mu.Lock() - defer s.mu.Unlock() - s.allowRootLogin = allow -} - -// userNameLookup performs user lookup with root login permission check -func (s *Server) userNameLookup(username string) (*user.User, error) { - result := s.CheckPrivileges(PrivilegeCheckRequest{ - RequestedUsername: username, - FeatureSupportsUserSwitch: true, - FeatureName: FeatureSSHLogin, - }) - - if !result.Allowed { - return nil, result.Error - } - - return result.User, nil -} - -// userPrivilegeCheck performs user lookup with full privilege check result -func (s *Server) userPrivilegeCheck(username string) (PrivilegeCheckResult, error) { - result := s.CheckPrivileges(PrivilegeCheckRequest{ - RequestedUsername: username, - FeatureSupportsUserSwitch: true, - FeatureName: FeatureSSHLogin, - }) - - if !result.Allowed { - return result, result.Error - } - - return result, nil -} - -// isPrivilegedUsername checks if the given username represents a privileged user across platforms. -// On Unix: root -// On Windows: Administrator, SYSTEM (case-insensitive) -// Handles domain-qualified usernames like "DOMAIN\Administrator" or "user@domain.com" -func isPrivilegedUsername(username string) bool { +// isPrivilegedOrUnknown reports whether the given username represents a +// privileged user, or on Windows an account whose privilege could not be +// determined. +// On Unix: root. +// On Windows: well-known service accounts, built-in Administrator accounts, +// and members of the local Administrators group; handles domain-qualified +// usernames like "DOMAIN\user" or "user@domain.com". An account that cannot be +// resolved or evaluated is reported as privileged. +// +// Use this to refuse privileged accounts, never to grant them anything: the +// undetermined case is safe for a refusal and unsafe for a grant. +func isPrivilegedOrUnknown(username string) bool { if getCurrentOS() != "windows" { return username == "root" } - - bareUsername := username - // Handle Windows domain format: DOMAIN\username - if idx := strings.LastIndex(username, `\`); idx != -1 { - bareUsername = username[idx+1:] - } - // Handle email-style format: username@domain.com - if idx := strings.Index(bareUsername, "@"); idx != -1 { - bareUsername = bareUsername[:idx] - } - - return isWindowsPrivilegedUser(bareUsername) -} - -// isWindowsPrivilegedUser checks if a bare username (domain already stripped) represents a Windows privileged account -func isWindowsPrivilegedUser(bareUsername string) bool { - // common privileged usernames (case insensitive) - privilegedNames := []string{ - "administrator", - "admin", - "root", - "system", - "localsystem", - "networkservice", - "localservice", - } - - usernameLower := strings.ToLower(bareUsername) - for _, privilegedName := range privilegedNames { - if usernameLower == privilegedName { - return true - } - } - - // computer accounts (ending with $) are not privileged by themselves - // They only gain privileges through group membership or specific SIDs - - if targetUser, err := lookupUser(bareUsername); err == nil { - return isWindowsPrivilegedSID(targetUser.Uid) - } - - return false -} - -// isWindowsPrivilegedSID checks if a Windows SID represents a privileged account -func isWindowsPrivilegedSID(sid string) bool { - privilegedSIDs := []string{ - "S-1-5-18", // Local System (SYSTEM) - "S-1-5-19", // Local Service (NT AUTHORITY\LOCAL SERVICE) - "S-1-5-20", // Network Service (NT AUTHORITY\NETWORK SERVICE) - "S-1-5-32-544", // Administrators group (BUILTIN\Administrators) - "S-1-5-500", // Built-in Administrator account (local machine RID 500) - } - - for _, privilegedSID := range privilegedSIDs { - if sid == privilegedSID { - return true - } - } - - // Check for domain administrator accounts (RID 500 in any domain) - // Format: S-1-5-21-domain-domain-domain-500 - // This is reliable as RID 500 is reserved for the domain Administrator account - if strings.HasPrefix(sid, "S-1-5-21-") && strings.HasSuffix(sid, "-500") { - return true - } - - // Check for other well-known privileged RIDs in domain contexts - // RID 512 = Domain Admins group, RID 516 = Domain Controllers group - if strings.HasPrefix(sid, "S-1-5-21-") { - if strings.HasSuffix(sid, "-512") || // Domain Admins group - strings.HasSuffix(sid, "-516") || // Domain Controllers group - strings.HasSuffix(sid, "-519") { // Enterprise Admins group - return true - } - } - - return false + return getWindowsAccountPrivilegedOrUnknown(username) } // isCurrentProcessPrivileged checks if the current process is running with elevated privileges. // On Unix systems, this means running as root (UID 0). -// On Windows, this means running as Administrator or SYSTEM. +// On Windows, this means the process token is elevated (administrators, SYSTEM). func isCurrentProcessPrivileged() bool { if getCurrentOS() == "windows" { - return isWindowsElevated() + return getProcessElevated() } return getEuid() == 0 } - -// isWindowsElevated checks if the current process is running with elevated privileges on Windows -func isWindowsElevated() bool { - currentUser, err := getCurrentUser() - if err != nil { - log.Errorf("failed to get current user for privilege check, assuming non-privileged: %v", err) - return false - } - - if isWindowsPrivilegedSID(currentUser.Uid) { - log.Debugf("Windows user switching supported: running as privileged SID %s", currentUser.Uid) - return true - } - - if isPrivilegedUsername(currentUser.Username) { - log.Debugf("Windows user switching supported: running as privileged username %s", currentUser.Username) - return true - } - - log.Debugf("Windows user switching not supported: not running as privileged user (current: %s)", currentUser.Uid) - return false -} diff --git a/client/ssh/server/user_utils_test.go b/client/ssh/server/user_utils_test.go index 637dc10d0..2fa9b68ee 100644 --- a/client/ssh/server/user_utils_test.go +++ b/client/ssh/server/user_utils_test.go @@ -4,6 +4,7 @@ import ( "errors" "os/user" "runtime" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -27,8 +28,8 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri originalLookupUser := lookupUser originalGetCurrentOS := getCurrentOS originalGetEuid := getEuid - - // Reset caches to ensure clean test state + originalGetProcessElevated := getProcessElevated + originalGetWindowsAccountPrivilegedOrUnknown := getWindowsAccountPrivilegedOrUnknown // Set test values - inject platform dependencies getCurrentUser = func() (*user.User, error) { @@ -53,16 +54,31 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri return euid } - // Mock privilege detection based on the test user - getIsProcessPrivileged = func() bool { + // Simulate the Windows token elevation check based on the fixture user: + // the built-in Administrator (RID 500) and SYSTEM run elevated. + getProcessElevated = func() bool { if currentUser == nil { return false } - // Check both username and SID for Windows systems - if os == "windows" && isWindowsPrivilegedSID(currentUser.Uid) { + return currentUser.Uid == "S-1-5-18" || strings.HasSuffix(currentUser.Uid, "-500") + } + + // Simulate the Windows account classifier for the fixture accounts. + // "root" does not exist on Windows; the real classifier fails closed on + // unresolvable accounts, so it counts as privileged here too. + getWindowsAccountPrivilegedOrUnknown = func(username string) bool { + bare := username + if idx := strings.LastIndex(bare, `\`); idx != -1 { + bare = bare[idx+1:] + } + if idx := strings.Index(bare, "@"); idx != -1 { + bare = bare[:idx] + } + switch strings.ToLower(bare) { + case "administrator", "system", "root": return true } - return isPrivilegedUsername(currentUser.Username) + return false } // Return cleanup function @@ -71,10 +87,8 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri lookupUser = originalLookupUser getCurrentOS = originalGetCurrentOS getEuid = originalGetEuid - - getIsProcessPrivileged = isCurrentProcessPrivileged - - // Reset caches after test + getProcessElevated = originalGetProcessElevated + getWindowsAccountPrivilegedOrUnknown = originalGetWindowsAccountPrivilegedOrUnknown } } @@ -421,6 +435,9 @@ func TestUsedFallback_MeansNoPrivilegeDropping(t *testing.T) { } func TestPrivilegedUsernameDetection(t *testing.T) { + // Windows classification is syscall-backed (SID resolution, group + // membership) and is covered by privileges_windows_test.go; here only the + // Unix logic and the platform dispatch are exercised. tests := []struct { name string username string @@ -432,25 +449,9 @@ func TestPrivilegedUsernameDetection(t *testing.T) { {"unix_regular_user", "alice", "linux", false}, {"unix_root_capital", "Root", "linux", false}, // Case-sensitive - // Windows tests + // Windows dispatch to the (mocked) account classifier {"windows_administrator", "Administrator", "windows", true}, - {"windows_system", "SYSTEM", "windows", true}, - {"windows_admin", "admin", "windows", true}, - {"windows_admin_lowercase", "administrator", "windows", true}, // Case-insensitive - {"windows_domain_admin", "DOMAIN\\Administrator", "windows", true}, - {"windows_email_admin", "admin@domain.com", "windows", true}, {"windows_regular_user", "alice", "windows", false}, - {"windows_domain_user", "DOMAIN\\alice", "windows", false}, - {"windows_localsystem", "localsystem", "windows", true}, - {"windows_networkservice", "networkservice", "windows", true}, - {"windows_localservice", "localservice", "windows", true}, - - // Computer accounts (these depend on current user context in real implementation) - {"windows_computer_account", "WIN2K19-C2$", "windows", false}, // Computer account by itself not privileged - {"windows_domain_computer", "DOMAIN\\COMPUTER$", "windows", false}, // Domain computer account - - // Cross-platform - {"root_on_windows", "root", "windows", true}, // Root should be privileged everywhere } for _, tt := range tests { @@ -459,50 +460,8 @@ func TestPrivilegedUsernameDetection(t *testing.T) { cleanup := setupTestDependencies(nil, nil, tt.platform, 1000, nil, nil) defer cleanup() - result := isPrivilegedUsername(tt.username) - assert.Equal(t, tt.privileged, result) - }) - } -} - -func TestWindowsPrivilegedSIDDetection(t *testing.T) { - tests := []struct { - name string - sid string - privileged bool - description string - }{ - // Well-known system accounts - {"system_account", "S-1-5-18", true, "Local System (SYSTEM)"}, - {"local_service", "S-1-5-19", true, "Local Service"}, - {"network_service", "S-1-5-20", true, "Network Service"}, - {"administrators_group", "S-1-5-32-544", true, "Administrators group"}, - {"builtin_administrator", "S-1-5-500", true, "Built-in Administrator"}, - - // Domain accounts - {"domain_administrator", "S-1-5-21-1234567890-1234567890-1234567890-500", true, "Domain Administrator (RID 500)"}, - {"domain_admins_group", "S-1-5-21-1234567890-1234567890-1234567890-512", true, "Domain Admins group"}, - {"domain_controllers_group", "S-1-5-21-1234567890-1234567890-1234567890-516", true, "Domain Controllers group"}, - {"enterprise_admins_group", "S-1-5-21-1234567890-1234567890-1234567890-519", true, "Enterprise Admins group"}, - - // Regular users - {"regular_user", "S-1-5-21-1234567890-1234567890-1234567890-1001", false, "Regular domain user"}, - {"another_regular_user", "S-1-5-21-1234567890-1234567890-1234567890-1234", false, "Another regular user"}, - {"local_user", "S-1-5-21-1234567890-1234567890-1234567890-1000", false, "Local regular user"}, - - // Groups that are not privileged - {"domain_users", "S-1-5-21-1234567890-1234567890-1234567890-513", false, "Domain Users group"}, - {"power_users", "S-1-5-32-547", false, "Power Users group"}, - - // Invalid SIDs - {"malformed_sid", "S-1-5-invalid", false, "Malformed SID"}, - {"empty_sid", "", false, "Empty SID"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := isWindowsPrivilegedSID(tt.sid) - assert.Equal(t, tt.privileged, result, "Failed for %s: %s", tt.description, tt.sid) + result := isPrivilegedOrUnknown(tt.username) + assert.Equal(t, tt.privileged, result, "privilege classification for %s on %s", tt.username, tt.platform) }) } } diff --git a/client/ssh/server/userswitching_unix.go b/client/ssh/server/userswitching_unix.go index 220e2240f..ae60ec64c 100644 --- a/client/ssh/server/userswitching_unix.go +++ b/client/ssh/server/userswitching_unix.go @@ -16,6 +16,8 @@ import ( "github.com/gliderlabs/ssh" log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/getent" ) // POSIX portable filename character set regex: [a-zA-Z0-9._-] @@ -160,7 +162,7 @@ func (s *Server) parseUserCredentials(localUser *user.User) (uint32, uint32, []u // getSupplementaryGroups retrieves supplementary group IDs for a user. // Uses id/getent fallback for NSS users in CGO_ENABLED=0 builds. func (s *Server) getSupplementaryGroups(u *user.User) ([]uint32, error) { - groupIDStrings, err := groupIdsWithFallback(u) + groupIDStrings, err := getent.GroupIDs(u) if err != nil { return nil, fmt.Errorf("get group IDs for user %s: %w", u.Username, err) } diff --git a/client/ssh/server/userswitching_windows.go b/client/ssh/server/userswitching_windows.go index 260e1301e..9e8cd5b30 100644 --- a/client/ssh/server/userswitching_windows.go +++ b/client/ssh/server/userswitching_windows.go @@ -91,7 +91,7 @@ func validateUsernameFormat(username string) error { func (s *Server) createExecutorCommand(logger *log.Entry, session ssh.Session, localUser *user.User, hasPty bool) (*exec.Cmd, func(), error) { logger.Debugf("creating Windows executor command for user %s (Pty: %v)", localUser.Username, hasPty) - username, _ := s.parseUsername(localUser.Username) + username, _ := parseUsername(localUser.Username) if err := validateUsername(username); err != nil { return nil, nil, fmt.Errorf("invalid username %q: %w", username, err) } @@ -102,7 +102,7 @@ func (s *Server) createExecutorCommand(logger *log.Entry, session ssh.Session, l // createUserSwitchCommand creates a command with Windows user switching. // Returns the command and a cleanup function that must be called after starting the process. func (s *Server) createUserSwitchCommand(logger *log.Entry, session ssh.Session, localUser *user.User) (*exec.Cmd, func(), error) { - username, domain := s.parseUsername(localUser.Username) + username, domain := parseUsername(localUser.Username) shell := getUserShell(localUser.Uid) @@ -138,7 +138,7 @@ func (s *Server) createUserSwitchCommand(logger *log.Entry, session ssh.Session, } // parseUsername extracts username and domain from a Windows username -func (s *Server) parseUsername(fullUsername string) (username, domain string) { +func parseUsername(fullUsername string) (username, domain string) { // Handle DOMAIN\username format if idx := strings.LastIndex(fullUsername, `\`); idx != -1 { domain = fullUsername[:idx] diff --git a/client/ssh/session.go b/client/ssh/session.go new file mode 100644 index 000000000..999b6f251 --- /dev/null +++ b/client/ssh/session.go @@ -0,0 +1,84 @@ +package ssh + +import ( + "fmt" + "io" + + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" +) + +// DefaultTerminalModes are the PTY modes used by the interactive terminal clients. +var DefaultTerminalModes = ssh.TerminalModes{ + ssh.ECHO: 1, + ssh.TTY_OP_ISPEED: 14400, + ssh.TTY_OP_OSPEED: 14400, + ssh.VINTR: 3, // Ctrl+C + ssh.VQUIT: 28, // Ctrl+\ + ssh.VERASE: 127, // Backspace + ssh.VKILL: 21, // Ctrl+U + ssh.VEOF: 4, // Ctrl+D + ssh.VEOL: 0, + ssh.VEOL2: 0, + ssh.VSTART: 17, // Ctrl+Q + ssh.VSTOP: 19, // Ctrl+S + ssh.VSUSP: 26, // Ctrl+Z + ssh.VDISCARD: 15, // Ctrl+O + ssh.VREPRINT: 18, // Ctrl+R + ssh.VWERASE: 23, // Ctrl+W + ssh.VLNEXT: 22, // Ctrl+V +} + +// PTYSession is an interactive shell session with a PTY and its I/O pipes. +type PTYSession struct { + Session *ssh.Session + Stdin io.WriteCloser + Stdout io.Reader + Stderr io.Reader +} + +// StartPTYSession opens a session on the client, requests an xterm-256color PTY +// with the default terminal modes, wires up the I/O pipes and starts a shell. +// The session is closed on any error. +func StartPTYSession(client *ssh.Client, cols, rows int) (*PTYSession, error) { + session, err := client.NewSession() + if err != nil { + return nil, fmt.Errorf("new session: %w", err) + } + + pty, err := setupPTYSession(session, cols, rows) + if err != nil { + if closeErr := session.Close(); closeErr != nil { + log.Debugf("ssh: session close after setup error: %v", closeErr) + } + return nil, err + } + return pty, nil +} + +// setupPTYSession requests the PTY, opens the pipes and starts the shell on an +// already created session. +func setupPTYSession(session *ssh.Session, cols, rows int) (*PTYSession, error) { + if err := session.RequestPty("xterm-256color", rows, cols, DefaultTerminalModes); err != nil { + return nil, fmt.Errorf("request pty: %w", err) + } + + stdin, err := session.StdinPipe() + if err != nil { + return nil, fmt.Errorf("stdin pipe: %w", err) + } + stdout, err := session.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("stdout pipe: %w", err) + } + stderr, err := session.StderrPipe() + if err != nil { + return nil, fmt.Errorf("stderr pipe: %w", err) + } + + if err := session.Shell(); err != nil { + return nil, fmt.Errorf("start shell: %w", err) + } + + return &PTYSession{Session: session, Stdin: stdin, Stdout: stdout, Stderr: stderr}, nil +} diff --git a/client/status/status.go b/client/status/status.go index e8276d0fa..1c204cdb1 100644 --- a/client/status/status.go +++ b/client/status/status.go @@ -46,7 +46,10 @@ func ParseDaemonStatus(s string) DaemonStatus { // ConvertOptions holds parameters for ConvertToStatusOutputOverview. type ConvertOptions struct { - Anonymize bool + Anonymize bool + // AnonymizeLevel selects how much the anonymizer redacts. Only + // meaningful when Anonymize is set. + AnonymizeLevel anonymize.Level DaemonVersion string DaemonStatus DaemonStatus StatusFilter string @@ -217,6 +220,7 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO if opts.Anonymize { anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + anonymizer.SetLevel(opts.AnonymizeLevel) anonymizeOverview(anonymizer, &overview) } @@ -976,6 +980,7 @@ func timeAgo(t time.Time) string { func anonymizePeerDetail(a *anonymize.Anonymizer, peer *PeerStateDetailOutput) { peer.FQDN = a.AnonymizeDomain(peer.FQDN) + peer.PubKey = a.AnonymizeWGKey(peer.PubKey) if localIP, port, err := net.SplitHostPort(peer.IceCandidateEndpoint.Local); err == nil { peer.IceCandidateEndpoint.Local = fmt.Sprintf("%s:%s", a.AnonymizeIPString(localIP), port) } @@ -1007,6 +1012,7 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) { overview.SignalState.URL = a.AnonymizeURI(overview.SignalState.URL) overview.SignalState.Error = a.AnonymizeString(overview.SignalState.Error) + overview.PubKey = a.AnonymizeWGKey(overview.PubKey) overview.IP = a.AnonymizeIPString(overview.IP) overview.IPv6 = a.AnonymizeIPString(overview.IPv6) for i, detail := range overview.Relays.Details { diff --git a/client/system/info.go b/client/system/info.go index 1838204b8..273c7a533 100644 --- a/client/system/info.go +++ b/client/system/info.go @@ -65,6 +65,7 @@ type Info struct { RosenpassEnabled bool RosenpassPermissive bool ServerSSHAllowed bool + RemoteJobsAllowed bool DisableClientRoutes bool DisableServerRoutes bool @@ -79,21 +80,27 @@ type Info struct { EnableSSHLocalPortForwarding bool EnableSSHRemotePortForwarding bool DisableSSHAuth bool + + SyncMessageVersion *int } func (i *Info) SetFlags( rosenpassEnabled, rosenpassPermissive bool, serverSSHAllowed *bool, disableClientRoutes, disableServerRoutes, - disableDNS, disableFirewall, blockLANAccess, blockInbound, disableIPv6 bool, + disableDNS, disableFirewall, blockLANAccess, blockInbound, disableIPv6 bool, syncMessageVersion *int, enableSSHRoot, enableSSHSFTP, enableSSHLocalPortForwarding, enableSSHRemotePortForwarding *bool, disableSSHAuth *bool, + remoteJobsAllowed *bool, ) { i.RosenpassEnabled = rosenpassEnabled i.RosenpassPermissive = rosenpassPermissive if serverSSHAllowed != nil { i.ServerSSHAllowed = *serverSSHAllowed } + if remoteJobsAllowed != nil { + i.RemoteJobsAllowed = *remoteJobsAllowed + } i.DisableClientRoutes = disableClientRoutes i.DisableServerRoutes = disableServerRoutes @@ -103,6 +110,8 @@ func (i *Info) SetFlags( i.BlockInbound = blockInbound i.DisableIPv6 = disableIPv6 + i.SyncMessageVersion = syncMessageVersion + if enableSSHRoot != nil { i.EnableSSHRoot = *enableSSHRoot } diff --git a/client/system/info_android.go b/client/system/info_android.go index 3c71573bb..d4f479386 100644 --- a/client/system/info_android.go +++ b/client/system/info_android.go @@ -30,6 +30,11 @@ func GetInfo(ctx context.Context) *Info { kernelVersion = osInfo[2] } + addrs, err := networkAddresses() + if err != nil { + log.Warnf("discover network addresses: %s", err) + } + gio := &Info{ GoOS: runtime.GOOS, Kernel: kernel, @@ -41,6 +46,7 @@ func GetInfo(ctx context.Context) *Info { NetbirdVersion: version.NetbirdVersion(), UIVersion: extractUIVersion(ctx), KernelVersion: kernelVersion, + NetworkAddresses: addrs, SystemSerialNumber: serial(), SystemProductName: productModel(), SystemManufacturer: productManufacturer(), diff --git a/client/system/info_js.go b/client/system/info_js.go index f32532881..3323fb542 100644 --- a/client/system/info_js.go +++ b/client/system/info_js.go @@ -15,7 +15,7 @@ func UpdateStaticInfoAsync() { } // GetInfo retrieves system information for WASM environment -func GetInfo(_ context.Context) *Info { +func GetInfo(ctx context.Context) *Info { info := &Info{ GoOS: runtime.GOOS, Kernel: runtime.GOARCH, @@ -30,6 +30,13 @@ func GetInfo(_ context.Context) *Info { collectBrowserInfo(info) collectLocationInfo(info) collectSystemInfo(info) + + // A caller-provided device name wins, as on the other platforms. A peer + // registered over an API keeps reporting the name it was registered with, + // so its meta does not change on the first sync. + if name := extractDeviceName(ctx, info.Hostname); name != "" { + info.Hostname = name + } return info } diff --git a/client/system/info_js_test.go b/client/system/info_js_test.go new file mode 100644 index 000000000..e2a33ada0 --- /dev/null +++ b/client/system/info_js_test.go @@ -0,0 +1,27 @@ +//go:build js + +package system + +import ( + "context" + "testing" +) + +// TestGetInfoHonorsDeviceName covers a caller-provided device name reaching the +// reported hostname, so a peer registered over an API keeps reporting the name +// it was registered with instead of renaming itself on its first sync. +func TestGetInfoHonorsDeviceName(t *testing.T) { + ctx := context.WithValue(context.Background(), DeviceNameCtxKey, "session-name") + if got := GetInfo(ctx).Hostname; got != "session-name" { + t.Errorf("hostname should carry the caller's device name, got %q", got) + } +} + +// TestGetInfoWithoutDeviceNameKeepsFallback covers the embed layer's habit of +// always setting the context value: an empty name must not blank the hostname. +func TestGetInfoWithoutDeviceNameKeepsFallback(t *testing.T) { + ctx := context.WithValue(context.Background(), DeviceNameCtxKey, "") + if got := GetInfo(ctx).Hostname; got == "" { + t.Error("an empty device name must not blank the hostname") + } +} diff --git a/client/system/info_source.go b/client/system/info_source.go new file mode 100644 index 000000000..050e094e4 --- /dev/null +++ b/client/system/info_source.go @@ -0,0 +1,38 @@ +package system + +import ( + "context" + "net/netip" + "slices" + "sync/atomic" + "time" + + "github.com/netbirdio/netbird/shared/management/proto" +) + +// InfoSource gathers the system info sent to management, keeping the posture +// check results from the last Refresh for the cheap Current snapshots. +type InfoSource struct { + files atomic.Pointer[[]File] +} + +// Refresh gathers the info with the posture checks evaluated, bounded by timeout. +func (s *InfoSource) Refresh(ctx context.Context, timeout time.Duration, checks []*proto.Checks, excludeIPs ...netip.Addr) (*Info, bool) { + info, ok := GetInfoWithChecksTimeout(ctx, timeout, checks, excludeIPs...) + if !ok { + return nil, false + } + files := slices.Clone(info.Files) + s.files.Store(&files) + return info, true +} + +// Current gathers the info without evaluating the checks, reusing the last Refresh results. +func (s *InfoSource) Current(ctx context.Context, excludeIPs ...netip.Addr) *Info { + info := GetInfo(ctx) + info.removeAddresses(excludeIPs...) + if files := s.files.Load(); files != nil { + info.Files = *files + } + return info +} diff --git a/client/system/info_source_test.go b/client/system/info_source_test.go new file mode 100644 index 000000000..1c86806af --- /dev/null +++ b/client/system/info_source_test.go @@ -0,0 +1,59 @@ +package system + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/shared/management/proto" +) + +func TestInfoSource_CurrentBeforeRefresh(t *testing.T) { + var src InfoSource + + info := src.Current(context.Background()) + + assert.Empty(t, info.Files) +} + +func TestInfoSource_CurrentReusesRefreshedFiles(t *testing.T) { + path := filepath.Join(t.TempDir(), "agent") + require.NoError(t, os.WriteFile(path, nil, 0o600)) + checks := []*proto.Checks{{Files: []string{path}}} + + var src InfoSource + refreshed, ok := src.Refresh(context.Background(), 15*time.Second, checks) + require.True(t, ok) + require.Equal(t, []File{{Path: path, Exist: true}}, refreshed.Files) + + info := src.Current(context.Background()) + + assert.Equal(t, refreshed.Files, info.Files) +} + +func TestInfoSource_CurrentExcludesAddresses(t *testing.T) { + addrs := GetInfo(context.Background()).NetworkAddresses + if len(addrs) == 0 { + t.Skip("no network addresses on this host") + } + excluded := addrs[0].NetIP.Addr() + matching := 0 + for _, addr := range addrs { + if addr.NetIP.Addr() == excluded { + matching++ + } + } + + var src InfoSource + info := src.Current(context.Background(), excluded) + + assert.Len(t, info.NetworkAddresses, len(addrs)-matching) + for _, addr := range info.NetworkAddresses { + assert.NotEqual(t, excluded, addr.NetIP.Addr()) + } +} diff --git a/client/system/network_addr.go b/client/system/network_addr.go index 44260a938..505a6f0ea 100644 --- a/client/system/network_addr.go +++ b/client/system/network_addr.go @@ -1,4 +1,4 @@ -//go:build !ios +//go:build !ios && !android package system diff --git a/client/system/network_addr_android.go b/client/system/network_addr_android.go new file mode 100644 index 000000000..99a71e105 --- /dev/null +++ b/client/system/network_addr_android.go @@ -0,0 +1,89 @@ +package system + +import ( + "net/netip" + "strings" +) + +var iFaceDiscover IFaceDiscover + +type IFaceDiscover interface { + IFaces() (string, error) +} + +// SetIFaceDiscover configures the Android interface discovery provider. +func SetIFaceDiscover(discover IFaceDiscover) { + iFaceDiscover = discover +} + +func networkAddresses() ([]NetworkAddress, error) { + if iFaceDiscover == nil { + return nil, nil + } + ifaces, err := iFaceDiscover.IFaces() + if err != nil { + return nil, err + } + + var netAddresses []NetworkAddress + for _, line := range strings.Split(ifaces, "\n") { + addresses, ok := interfaceAddresses(line) + if !ok { + continue + } + for _, address := range addresses { + netAddr, ok := toNetworkAddress(address) + if !ok { + continue + } + if isDuplicated(netAddresses, netAddr) { + continue + } + netAddresses = append(netAddresses, netAddr) + } + } + return netAddresses, nil +} + +func interfaceAddresses(line string) ([]string, bool) { + parts := strings.Split(line, "|") + if len(parts) != 2 { + return nil, false + } + flags := strings.Fields(parts[0]) + if len(flags) != 8 { + return nil, false + } + up, loopback := flags[3], flags[5] + if up != "true" || loopback == "true" { + return nil, false + } + return strings.Fields(parts[1]), true +} + +func toNetworkAddress(address string) (NetworkAddress, bool) { + prefix, err := netip.ParsePrefix(address) + if err != nil { + return NetworkAddress{}, false + } + if prefix.Addr().Is4In6() { + if prefix.Bits() < 96 { + return NetworkAddress{}, false + } + prefix = netip.PrefixFrom(prefix.Addr().Unmap(), prefix.Bits()-96) + } + ip := prefix.Addr() + if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsMulticast() { + return NetworkAddress{}, false + } + return NetworkAddress{NetIP: prefix}, true +} + +func isDuplicated(addresses []NetworkAddress, addr NetworkAddress) bool { + for _, duplicated := range addresses { + if duplicated.NetIP == addr.NetIP { + return true + } + } + return false +} diff --git a/client/system/network_addr_test.go b/client/system/network_addr_test.go index a5f9c4279..b0be40f0a 100644 --- a/client/system/network_addr_test.go +++ b/client/system/network_addr_test.go @@ -1,4 +1,4 @@ -//go:build !ios +//go:build !ios && !android package system diff --git a/client/system/process_test.go b/client/system/process_test.go index 9d0a6b935..de1cfc1db 100644 --- a/client/system/process_test.go +++ b/client/system/process_test.go @@ -1,3 +1,5 @@ +//go:build windows || (linux && !android) || (darwin && !ios) || freebsd + package system import ( diff --git a/client/testutil/privileged/runner_test.go b/client/testutil/privileged/runner_test.go index d1945894d..157005d3e 100644 --- a/client/testutil/privileged/runner_test.go +++ b/client/testutil/privileged/runner_test.go @@ -25,7 +25,7 @@ import ( // (.github/workflows/golang-test-linux.yml, test_client_on_docker). const ( containerImage = "golang" - containerTag = "1.25-alpine" + containerTag = "1.26.7-alpine" ) const ( diff --git a/client/ui/authsession/service.go b/client/ui/authsession/service.go index 28efe7cfd..9c094c2a1 100644 --- a/client/ui/authsession/service.go +++ b/client/ui/authsession/service.go @@ -6,9 +6,11 @@ import ( "context" "time" + log "github.com/sirupsen/logrus" "google.golang.org/grpc/codes" gstatus "google.golang.org/grpc/status" + "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/proto" ) @@ -58,10 +60,21 @@ func (s *Session) RequestExtend(ctx context.Context, p ExtendStartParams) (Exten return ExtendStartResult{}, err } - req := &proto.RequestExtendAuthSessionRequest{} - if p.Hint != "" { - h := p.Hint - req.Hint = &h + // a request from the UI implies a graphical session, which the daemon cannot detect itself + req := &proto.RequestExtendAuthSessionRequest{HasGraphicalSession: true} + hint := p.Hint + if hint == "" { + pm := profilemanager.NewProfileManager() + if active, perr := pm.GetActiveProfile(); perr != nil { + log.Debugf("failed to get active profile for login hint: %v", perr) + } else if state, serr := pm.GetProfileState(active.ID); serr != nil { + log.Debugf("failed to get profile state for login hint: %v", serr) + } else { + hint = state.Email + } + } + if hint != "" { + req.Hint = &hint } resp, err := cli.RequestExtendAuthSession(ctx, req) diff --git a/client/ui/autostart_default.go b/client/ui/autostart_default.go index bf1b16a97..0c67667dd 100644 --- a/client/ui/autostart_default.go +++ b/client/ui/autostart_default.go @@ -51,7 +51,7 @@ func autostartDisabledByMDM(policy *mdm.Policy) bool { // netbirdFootprintExists reports whether the machine already carries NetBird // daemon config or state, meaning this is not a genuinely fresh install. It is // the update-safety gate for the autostart default: upgrading users always -// have a footprint, so an update can never trigger a login-item write. +// have a footprint, so an update can never trigger a autostart entry write. func netbirdFootprintExists() bool { candidates := []string{ profilemanager.DefaultConfigPath, @@ -69,9 +69,23 @@ func netbirdFootprintExists() bool { // applyAutostartDefault runs the one-time launch-on-login default for genuinely // fresh installs. The autostartInitialized marker is persisted before any // enable attempt so a crash mid-flow degrades to "never enabled" instead of -// retrying login-item writes on every launch. A user's later disable in +// retrying autostart entry writes on every launch. A user's later disable in // Settings is never overridden: the marker guarantees at-most-once, ever. func applyAutostartDefault(ctx context.Context, autostart *services.Autostart, prefs *preferences.Store, prefsFileExisted bool) { + mdmDisabled := autostartDisabledByMDM(mdm.NewLoader(nil).Load()) + + if mdmDisabled { + if enabled, err := autostart.IsEnabled(ctx); err != nil { + log.Warnf("MDM disableAutostart: read autostart state: %v", err) + } else if enabled { + if err := autostart.SetEnabled(ctx, false); err != nil { + log.Warnf("MDM disableAutostart: force off failed: %v", err) + } else { + log.Info("MDM disableAutostart enforced: autostart turned off") + } + } + } + priorFootprint := netbirdFootprintExists() || prefsFileExisted if prefs.Get().AutostartInitialized { @@ -84,7 +98,7 @@ func applyAutostartDefault(ctx context.Context, autostart *services.Autostart, p state := autostartDefaultState{ supported: autostart.Supported(ctx), - mdmDisabled: autostartDisabledByMDM(mdm.LoadPolicy()), + mdmDisabled: mdmDisabled, priorInstall: priorFootprint, } enable, reason := shouldEnableAutostartDefault(state) diff --git a/client/ui/build/docker/Dockerfile.cross b/client/ui/build/docker/Dockerfile.cross index a487b8db0..55c0d69e1 100644 --- a/client/ui/build/docker/Dockerfile.cross +++ b/client/ui/build/docker/Dockerfile.cross @@ -13,7 +13,7 @@ # docker run --rm -v $(pwd):/app wails-cross windows amd64 # docker run --rm -v $(pwd):/app wails-cross windows arm64 -FROM golang:1.25-bookworm +FROM golang:1.26.7-bookworm ARG TARGETARCH diff --git a/client/ui/build/docker/Dockerfile.server b/client/ui/build/docker/Dockerfile.server index 58fb64f76..57183f1d2 100644 --- a/client/ui/build/docker/Dockerfile.server +++ b/client/ui/build/docker/Dockerfile.server @@ -2,7 +2,7 @@ # Multi-stage build for minimal image size # Build stage -FROM golang:alpine AS builder +FROM golang:1.26.7-alpine AS builder WORKDIR /app diff --git a/client/ui/build/linux/netbird.desktop b/client/ui/build/linux/netbird.desktop index a81f3698a..0d43b62a2 100644 --- a/client/ui/build/linux/netbird.desktop +++ b/client/ui/build/linux/netbird.desktop @@ -1,5 +1,6 @@ [Desktop Entry] -Name=Netbird +Name=NetBird +Comment=NetBird desktop client Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 /usr/bin/netbird-ui Icon=netbird Type=Application diff --git a/client/ui/build/linux/nfpm/nfpm.yaml b/client/ui/build/linux/nfpm/nfpm.yaml index a05daef62..764855a63 100644 --- a/client/ui/build/linux/nfpm/nfpm.yaml +++ b/client/ui/build/linux/nfpm/nfpm.yaml @@ -26,17 +26,17 @@ contents: # Default dependencies for the GTK4 + WebKitGTK 6.0 stack (Ubuntu 24.04+ / Debian 13+) depends: - - libgtk-4-1 + - libgtk-4-1 (>= 4.14) - libwebkitgtk-6.0-4 - xdg-utils # Distribution-specific overrides for different package formats overrides: - # RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux + # RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux / openSUSE rpm: depends: - - gtk4 - - webkitgtk6.0 + - (gtk4 >= 4.14 or libgtk-4-1 >= 4.14) + - (webkitgtk6.0 or libwebkitgtk-6_0-4) - xdg-utils # Arch Linux packages diff --git a/client/ui/build/linux/polkit/io.netbird.settings.policy b/client/ui/build/linux/polkit/io.netbird.settings.policy new file mode 100644 index 000000000..e12f1ddc7 --- /dev/null +++ b/client/ui/build/linux/polkit/io.netbird.settings.policy @@ -0,0 +1,47 @@ + + + + + + NetBird + https://netbird.io + + + Change privileged NetBird settings + Authentication is required to change NetBird settings that grant SSH access to this computer. + netbird + + auth_admin + auth_admin + auth_admin + + /usr/bin/netbird-ui + --apply-privileged-settings + + + + Change privileged NetBird settings + Authentication is required to change NetBird settings that grant SSH access to this computer. + netbird + + auth_admin + auth_admin + auth_admin + + /usr/local/bin/netbird-ui + --apply-privileged-settings + + diff --git a/client/ui/frontend/index.html b/client/ui/frontend/index.html index e62139956..f4c8b3d68 100644 --- a/client/ui/frontend/index.html +++ b/client/ui/frontend/index.html @@ -6,7 +6,25 @@ NetBird +
diff --git a/client/ui/frontend/package.json b/client/ui/frontend/package.json index 3131b36cd..dcef99ad3 100644 --- a/client/ui/frontend/package.json +++ b/client/ui/frontend/package.json @@ -15,7 +15,8 @@ "lint": "eslint \"src/**/*.{ts,tsx}\"", "lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix", "check": "pnpm lint && pnpm typecheck && pnpm format:check", - "check:fix": "pnpm lint:fix && pnpm format && pnpm typecheck" + "check:fix": "pnpm lint:fix && pnpm format && pnpm typecheck", + "i18n:check": "node ../i18n/check-translations.mjs" }, "dependencies": { "@radix-ui/react-dialog": "^1.1.15", diff --git a/client/ui/frontend/src/app.tsx b/client/ui/frontend/src/app.tsx index 7f1359510..6accda36f 100644 --- a/client/ui/frontend/src/app.tsx +++ b/client/ui/frontend/src/app.tsx @@ -13,6 +13,7 @@ import { SkeletonTheme } from "react-loading-skeleton"; import "react-loading-skeleton/dist/skeleton.css"; import { welcome } from "@/lib/welcome"; import LoginWaitingForBrowserDialog from "@/modules/login/LoginWaitingForBrowserDialog.tsx"; +import { ThemeProvider } from "@/contexts/ThemeContext.tsx"; import { initI18n } from "@/lib/i18n"; import { initPlatform } from "@/lib/platform"; import { initLogForwarding } from "@/lib/logs"; @@ -35,30 +36,38 @@ Promise.all([ ]).finally(() => { ReactDOM.createRoot(document.getElementById("root")!).render( - - - - - } - /> - } /> - } - /> - } /> - } /> - - }> - } /> - } /> - } /> - - - - + + + + + + } + /> + } + /> + } + /> + } /> + } /> + + }> + } /> + } /> + } /> + + + + + , ); }); diff --git a/client/ui/frontend/src/assets/logos/netbird-full-light.svg b/client/ui/frontend/src/assets/logos/netbird-full-light.svg new file mode 100644 index 000000000..3457b50c6 --- /dev/null +++ b/client/ui/frontend/src/assets/logos/netbird-full-light.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/client/ui/frontend/src/components/Badge.tsx b/client/ui/frontend/src/components/Badge.tsx index c5e2b5f22..a6f2e886a 100644 --- a/client/ui/frontend/src/components/Badge.tsx +++ b/client/ui/frontend/src/components/Badge.tsx @@ -11,12 +11,14 @@ type Props = HTMLAttributes & { }; const VARIANT_CLASSES: Record = { - info: "bg-sky-900 border border-sky-700 text-sky-200", + info: "bg-sky-100 border border-sky-300 text-sky-800 dark:bg-sky-900 dark:border-sky-700 dark:text-sky-200", neutral: "bg-nb-gray-900 border border-nb-gray-850 text-nb-gray-200", - brand: "bg-netbird/15 border border-netbird/30 text-netbird", - success: "bg-green-900 border border-green-700 text-green-200", - warning: "bg-yellow-900 border border-yellow-700 text-yellow-200", - danger: "bg-red-900 border border-red-700 text-red-200", + brand: "bg-netbird/15 border border-netbird/30 text-netbird-700 dark:text-netbird", + success: + "bg-green-100 border border-green-300 text-green-800 dark:bg-green-900 dark:border-green-700 dark:text-green-200", + warning: + "bg-yellow-100 border border-yellow-300 text-yellow-800 dark:bg-yellow-900 dark:border-yellow-700 dark:text-yellow-200", + danger: "bg-red-100 border border-red-300 text-red-800 dark:bg-red-900 dark:border-red-700 dark:text-red-200", }; export const Badge = forwardRef(function Badge( diff --git a/client/ui/frontend/src/components/CopyToClipboard.tsx b/client/ui/frontend/src/components/CopyToClipboard.tsx index 1b2a87da4..4af4ecc8f 100644 --- a/client/ui/frontend/src/components/CopyToClipboard.tsx +++ b/client/ui/frontend/src/components/CopyToClipboard.tsx @@ -18,6 +18,9 @@ type CopyToClipboardProps = { className?: string; iconClassName?: string; alwaysShowIcon?: boolean; + // wrap lets long content (a shell command, a path) break across lines + // instead of being truncated to one line. + wrap?: boolean; variant?: CopyToClipboardVariant; "aria-label"?: string; tabIndex?: number; @@ -32,6 +35,7 @@ export const CopyToClipboard = ({ className, iconClassName, alwaysShowIcon = false, + wrap = false, variant = "default", "aria-label": ariaLabel, tabIndex = 0, @@ -77,13 +81,14 @@ export const CopyToClipboard = ({ aria-live={"polite"} className={cn( "group/copy wails-no-draggable pointer-events-auto inline-flex cursor-default items-center gap-2 rounded-sm text-left outline-none", - "focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", + "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", className, )} > @@ -157,7 +157,7 @@ export function LanguagePicker() { placeholder={t("settings.general.language.search")} aria-label={t("settings.general.language.search")} className={cn( - "w-full bg-transparent text-xs text-nb-gray-100 placeholder:text-nb-gray-300", + "w-full bg-transparent text-xs text-nb-gray-100 placeholder:text-nb-gray-600 dark:placeholder:text-nb-gray-300", "border-none outline-none", )} /> diff --git a/client/ui/frontend/src/components/ReadySignal.tsx b/client/ui/frontend/src/components/ReadySignal.tsx new file mode 100644 index 000000000..0d040cabc --- /dev/null +++ b/client/ui/frontend/src/components/ReadySignal.tsx @@ -0,0 +1,18 @@ +import { useEffect, useRef } from "react"; +import { Events } from "@wailsio/runtime"; +import { useStatus } from "@/contexts/StatusContext.tsx"; + +const EVENT_WINDOW_PAINTED = "netbird:window-painted"; + +export const ReadySignal = () => { + const { isReady } = useStatus(); + const sent = useRef(false); + + useEffect(() => { + if (!isReady || sent.current) return; + sent.current = true; + void Events.Emit(EVENT_WINDOW_PAINTED); + }, [isReady]); + + return null; +}; diff --git a/client/ui/frontend/src/components/SquareIcon.tsx b/client/ui/frontend/src/components/SquareIcon.tsx index e904d2de5..aaf3b1100 100644 --- a/client/ui/frontend/src/components/SquareIcon.tsx +++ b/client/ui/frontend/src/components/SquareIcon.tsx @@ -5,7 +5,7 @@ import { cn } from "@/lib/cn"; export type SquareIconVariant = "default" | "info" | "warning" | "danger"; const variantClass: Record = { - default: "text-white", + default: "text-nb-gray-50", info: "text-sky-400", warning: "text-netbird", danger: "text-red-500", @@ -27,7 +27,7 @@ export const SquareIcon = ({
o.value === theme) ?? OPTIONS[0]; + const CurrentIcon = current.icon; + + const select = async (value: string) => { + if (busy || value === theme) return; + setBusy(true); + try { + await setTheme(value as ThemePreference); + } catch (e) { + await errorDialog({ + Title: t("settings.error.saveTitle"), + Message: formatErrorMessage(e), + }); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ + {t("settings.general.theme.help")} +
+
+ + + + + + void select(v)}> + {OPTIONS.map(({ value, icon: Icon, labelKey }) => ( + + + {t(labelKey)} + + ))} + + + +
+
+ ); +} diff --git a/client/ui/frontend/src/components/Tooltip.tsx b/client/ui/frontend/src/components/Tooltip.tsx index 2c77ba139..d7a85277a 100644 --- a/client/ui/frontend/src/components/Tooltip.tsx +++ b/client/ui/frontend/src/components/Tooltip.tsx @@ -81,12 +81,12 @@ export const Tooltip = ({ onPointerLeave={interactive ? scheduleClose : undefined} onPointerDownOutside={interactive ? undefined : (e) => e.preventDefault()} className={cn( - "z-50 select-none text-xs text-nb-gray-100 shadow-lg", + "z-50 select-none text-xs text-nb-gray-100 shadow-sm dark:shadow-lg", "data-[state=delayed-open]:animate-in data-[state=closed]:animate-out", "data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0", !interactive && "pointer-events-none", contentClassName ?? - "rounded-md border border-nb-gray-850 bg-nb-gray-900 px-2 py-1", + "rounded-md border border-nb-gray-800 bg-white px-2 py-1 dark:border-nb-gray-850 dark:bg-nb-gray-900", )} > {content} diff --git a/client/ui/frontend/src/components/VerticalTabs.tsx b/client/ui/frontend/src/components/VerticalTabs.tsx index 1aedf82a6..306850ee2 100644 --- a/client/ui/frontend/src/components/VerticalTabs.tsx +++ b/client/ui/frontend/src/components/VerticalTabs.tsx @@ -46,12 +46,12 @@ const Trigger = forwardRef(function VerticalTab (function VerticalTab aria-hidden={"true"} className={cn( "ml-2 shrink-0 transition-colors duration-150", - "text-nb-gray-400 group-data-[state=active]:text-nb-gray-100", + "text-nb-gray-350 dark:text-nb-gray-400", + "group-data-[state=active]:text-nb-gray-100", )} /> {title} diff --git a/client/ui/frontend/src/components/buttons/Button.tsx b/client/ui/frontend/src/components/buttons/Button.tsx index 6b151c17b..931988ead 100644 --- a/client/ui/frontend/src/components/buttons/Button.tsx +++ b/client/ui/frontend/src/components/buttons/Button.tsx @@ -24,71 +24,74 @@ const buttonVariants = cva( variants: { variant: { default: [ - "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", - "dark:border-gray-700/30 dark:bg-nb-gray dark:text-gray-400 dark:hover:bg-zinc-800/50 dark:hover:text-white dark:focus:ring-zinc-800/50", + "border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50", + "dark:border-gray-700/30 dark:bg-nb-gray dark:text-gray-400 dark:hover:bg-zinc-800/50 dark:hover:text-nb-gray-50 dark:focus:ring-zinc-800/50", ], primary: [ - "dark:text-gray-100 dark:ring-offset-neutral-950/50 dark:focus:ring-netbird-600/50 enabled:dark:bg-netbird enabled:dark:hover:bg-netbird-500/80 enabled:dark:hover:text-white disabled:dark:bg-nb-gray-900", - "enabled:bg-netbird enabled:text-white enabled:hover:bg-netbird-500 enabled:focus:ring-netbird-400/50", + "dark:text-gray-100 dark:ring-offset-neutral-950/50 dark:focus:ring-netbird-600/50 enabled:dark:bg-netbird enabled:dark:hover:bg-netbird-500/80 enabled:dark:hover:text-nb-gray-50 disabled:dark:bg-nb-gray-900", + "enabled:bg-netbird enabled:text-white enabled:hover:bg-netbird-500 enabled:focus:ring-netbird-400/50 disabled:bg-nb-gray-700", ], secondary: [ - "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", - "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", - "dark:border-gray-700/40 dark:bg-nb-gray-920 dark:text-gray-400 dark:hover:bg-nb-gray-910 dark:hover:text-white", + "border-neutral-200 bg-white text-neutral-900 hover:border-nb-gray-700 hover:bg-nb-gray-950 hover:text-black focus:ring-nb-gray-500/50 focus:ring-offset-0", + "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20 dark:focus:ring-offset-1", + "dark:border-gray-700/40 dark:bg-nb-gray-920 dark:text-gray-400 dark:hover:border-gray-700/40 dark:hover:bg-nb-gray-910 dark:hover:text-nb-gray-50", ], secondaryLighter: [ - "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50", "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", - "dark:border-gray-700/70 dark:bg-nb-gray-900/70 dark:text-gray-400 dark:hover:bg-nb-gray-800/60 dark:hover:text-white", + "dark:border-gray-700/70 dark:bg-nb-gray-900/70 dark:text-gray-400 dark:hover:bg-nb-gray-800/60 dark:hover:text-nb-gray-50", ], subtle: [ - "border-nb-gray-200 bg-nb-gray-50 text-nb-gray-900 hover:bg-nb-gray-100 focus:ring-nb-gray-200/60", + "border-neutral-200 bg-neutral-50 text-neutral-900 hover:bg-neutral-100 focus:ring-neutral-200/60", "dark:ring-offset-neutral-950/50 dark:focus:ring-nb-gray-200/40", "dark:border-nb-gray-200 dark:bg-nb-gray-50 dark:text-nb-gray-900 dark:hover:bg-nb-gray-100 dark:hover:text-nb-gray-950", ], input: [ - "border-neutral-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50", "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", "dark:border-nb-gray-700 dark:bg-nb-gray-900 dark:text-gray-400 dark:hover:bg-nb-gray-900/80", ], dropdown: [ - "border-neutral-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50", "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", "dark:border-nb-gray-900 dark:bg-nb-gray-900/40 dark:text-gray-400 dark:hover:bg-nb-gray-900/50", ], dotted: [ - "border-dashed border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "border-dashed border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50", "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", - "dark:border-gray-500/40 dark:bg-nb-gray-900/30 dark:text-gray-400 dark:hover:bg-nb-gray-900/50 dark:hover:text-white", + "dark:border-gray-500/40 dark:bg-nb-gray-900/30 dark:text-gray-400 dark:hover:bg-nb-gray-900/50 dark:hover:text-nb-gray-50", ], tertiary: [ - "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50", "dark:border-gray-700/40 dark:bg-white dark:text-gray-800 dark:hover:bg-neutral-200 dark:focus:ring-zinc-800/50 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300", ], white: [ - "border-white bg-white text-gray-800 outline-none hover:bg-neutral-200 focus:ring-white/50 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300", + "border-white bg-white text-neutral-800 outline-none hover:bg-neutral-200 focus:ring-white/50 dark:text-gray-800 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300", "disabled:dark:border-nb-gray-900 disabled:dark:bg-nb-gray-900 disabled:dark:text-nb-gray-300", ], outline: [ - "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50", "dark:border-netbird dark:bg-transparent dark:text-netbird dark:hover:bg-nb-gray-900/30 dark:focus:ring-zinc-800/50", ], "danger-outline": [ + "bg-transparent text-red-600 enabled:hover:bg-red-50 enabled:focus:ring-red-200/50", "dark:bg-transparent dark:text-red-500 enabled:dark:hover:border-red-800/50 enabled:hover:dark:bg-red-950/50 enabled:dark:focus:bg-red-950/40 enabled:dark:focus:ring-red-800/20", ], "danger-text": [ - "rounded-sm !px-0 !py-0 !shadow-none focus:ring-red-500/30 dark:border-transparent dark:bg-transparent dark:text-red-500 dark:ring-offset-neutral-950/50 dark:hover:text-red-600", + "rounded-sm border-transparent bg-transparent !px-0 !py-0 text-red-600 !shadow-none hover:text-red-700 focus:ring-red-500/30", + "dark:border-transparent dark:bg-transparent dark:text-red-500 dark:ring-offset-neutral-950/50 dark:hover:text-red-600", ], "default-outline": [ - "dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20", - "dark:border-transparent dark:bg-transparent dark:text-nb-gray-400 dark:hover:border-nb-gray-800/50 dark:hover:bg-nb-gray-900/30 dark:hover:text-white", - "data-[state=open]:dark:border-nb-gray-800/50 data-[state=open]:dark:bg-nb-gray-900/30 data-[state=open]:dark:text-white", + "ring-offset-nb-gray-950/50 focus:ring-nb-gray-500/20", + "border-transparent bg-transparent text-nb-gray-400 hover:border-nb-gray-800/50 hover:bg-nb-gray-900/30 hover:text-nb-gray-50", + "data-[state=open]:border-nb-gray-800/50 data-[state=open]:bg-nb-gray-900/30 data-[state=open]:text-nb-gray-50", ], ghost: [ - "dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20", - "dark:border-transparent dark:bg-transparent dark:text-nb-gray-400 dark:hover:bg-nb-gray-900/30 dark:hover:text-white", + "ring-offset-nb-gray-950/50 focus:ring-nb-gray-500/20", + "border-transparent bg-transparent text-nb-gray-400 hover:bg-nb-gray-900/30 hover:text-nb-gray-50", ], danger: [ + "bg-red-600 text-red-50 hover:bg-red-700 focus:bg-red-700 focus:ring-red-700/20", "dark:bg-red-600 dark:text-red-100 dark:hover:border-red-800/50 hover:dark:bg-red-700 dark:focus:bg-red-700 dark:focus:ring-red-700/20", ], }, diff --git a/client/ui/frontend/src/components/buttons/IconButton.tsx b/client/ui/frontend/src/components/buttons/IconButton.tsx index 3d36bc111..8d688cb0b 100644 --- a/client/ui/frontend/src/components/buttons/IconButton.tsx +++ b/client/ui/frontend/src/components/buttons/IconButton.tsx @@ -24,7 +24,7 @@ export const IconButton = forwardRef(function IconButt "flex h-10 w-10 cursor-default items-center justify-center rounded-lg outline-none", "text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-300", isFocusVisible && - "focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", + "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", "wails-no-draggable transition-colors duration-150", className, )} diff --git a/client/ui/frontend/src/components/dialog/Dialog.tsx b/client/ui/frontend/src/components/dialog/Dialog.tsx index fa8007d9f..c43c04b0b 100644 --- a/client/ui/frontend/src/components/dialog/Dialog.tsx +++ b/client/ui/frontend/src/components/dialog/Dialog.tsx @@ -23,7 +23,7 @@ const Overlay = forwardRef, OverlayPr ref={ref} className={cn( "fixed inset-0 z-50 grid items-center justify-items-center overflow-y-auto px-10 py-16", - "bg-black/60", + "bg-black/25 dark:bg-black/60", "data-[state=open]:animate-in data-[state=open]:fade-in-0", exitAnimation && "data-[state=closed]:animate-out data-[state=closed]:fade-out-0", @@ -67,7 +67,7 @@ export const Content = forwardRef, Co className={cn( "relative z-[52] mx-auto w-full outline-none ring-0", "focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0", - "rounded-lg border border-nb-gray-900 bg-nb-gray py-7 shadow-2xl", + "rounded-lg border border-nb-gray-800 bg-nb-gray-940 py-7 shadow-2xl dark:border-nb-gray-900 dark:bg-nb-gray", "data-[state=open]:animate-in data-[state=open]:fade-in-0", "data-[state=open]:zoom-in-95 data-[state=open]:slide-in-from-left-1", exitAnimation && diff --git a/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx b/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx index 4ee8c2740..e8e7108eb 100644 --- a/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx +++ b/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx @@ -1,10 +1,13 @@ +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { AlertTriangleIcon, DownloadIcon } from "lucide-react"; import { Browser } from "@wailsio/runtime"; +import { Version } from "@bindings/services"; import { Button } from "@/components/buttons/Button"; import { useStatus } from "@/contexts/StatusContext.tsx"; const RELEASES_URL = "https://github.com/netbirdio/netbird/releases/latest"; +const RC_RELEASES_URL = "https://pkgs.netbird.io/releases/rc"; function openUrl(url: string) { Browser.OpenURL(url).catch(() => globalThis.open(url, "_blank")); @@ -12,7 +15,26 @@ function openUrl(url: string) { export const DaemonOutdatedOverlay = () => { const { t } = useTranslation(); - const { isDaemonOutdated } = useStatus(); + const { status, isDaemonOutdated } = useStatus(); + + const [guiVersion, setGuiVersion] = useState("-"); + const clientVersion = status?.daemonVersion ?? "—"; + + const isRc = /-rc/i.test(guiVersion) || /-rc/i.test(clientVersion); + const downloadUrl = isRc ? RC_RELEASES_URL : RELEASES_URL; + + useEffect(() => { + if (!isDaemonOutdated) return; + let cancelled = false; + Version.GUI() + .then((v) => { + if (!cancelled) setGuiVersion(v); + }) + .catch((err) => console.error("[DaemonOutdatedOverlay] GUI version error", err)); + return () => { + cancelled = true; + }; + }, [isDaemonOutdated]); if (!isDaemonOutdated) return null; @@ -38,10 +60,37 @@ export const DaemonOutdatedOverlay = () => {

{t("daemon.outdated.description")}

+
+

+ {clientVersion === "development" ? ( + + {t("settings.about.clientName")}{" "} + + {t("settings.about.development")} + + + ) : ( + t("settings.about.client", { version: clientVersion }) + )} +

+

+ {guiVersion === "development" ? ( + + {t("settings.about.guiName")}{" "} + + {t("settings.about.development")} + + + ) : ( + t("settings.about.gui", { version: guiVersion }) + )} +

+
+
-
diff --git a/client/ui/frontend/src/components/inputs/Input.tsx b/client/ui/frontend/src/components/inputs/Input.tsx index 2dad80d7a..eada79a1f 100644 --- a/client/ui/frontend/src/components/inputs/Input.tsx +++ b/client/ui/frontend/src/components/inputs/Input.tsx @@ -32,19 +32,19 @@ const inputVariants = cva("", { variants: { variant: { default: [ - "border-neutral-200 placeholder:text-neutral-500 dark:border-nb-gray-700 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70", + "border-neutral-200 placeholder:text-nb-gray-600 dark:border-nb-gray-700 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70", "ring-offset-neutral-200/20 focus-visible:ring-neutral-300/10 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20", ], darker: [ - "border-neutral-300 placeholder:text-neutral-500 dark:border-nb-gray-800 dark:bg-nb-gray-920 dark:placeholder:text-neutral-400/70", + "border-neutral-300 placeholder:text-nb-gray-600 dark:border-nb-gray-800 dark:bg-nb-gray-920 dark:placeholder:text-neutral-400/70", "ring-offset-neutral-200/20 focus-visible:ring-neutral-300/10 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20", ], error: [ - "border-neutral-200 text-red-500 placeholder:text-neutral-500 dark:border-red-500 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70", + "border-neutral-200 text-red-500 placeholder:text-nb-gray-600 dark:border-red-500 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70", "ring-offset-red-500/10 focus-visible:ring-red-500/10 dark:ring-offset-red-500/10 dark:focus-visible:ring-red-500/10", ], warning: [ - "border-neutral-200 text-orange-400 placeholder:text-neutral-500 dark:border-orange-400 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70", + "border-neutral-200 text-orange-400 placeholder:text-nb-gray-600 dark:border-orange-400 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70", "ring-offset-orange-400/10 focus-visible:ring-orange-400/10 dark:ring-offset-orange-400/10 dark:focus-visible:ring-orange-400/10", ], }, @@ -158,7 +158,7 @@ function NumberStepper({ className={cn( "flex h-[40px] shrink-0 flex-col overflow-hidden", "rounded-r-md border border-l-0", - "border-neutral-200 dark:border-nb-gray-700 dark:bg-nb-gray-900", + "border-neutral-200 bg-white dark:border-nb-gray-700 dark:bg-nb-gray-900", error && "dark:border-red-500", disabled && "pointer-events-none opacity-40", )} @@ -274,7 +274,9 @@ export const Input = forwardRef(function Input( ) : ( - diff --git a/client/ui/frontend/src/modules/error/ErrorDialog.tsx b/client/ui/frontend/src/modules/error/ErrorDialog.tsx index f7929fad2..4fbb78052 100644 --- a/client/ui/frontend/src/modules/error/ErrorDialog.tsx +++ b/client/ui/frontend/src/modules/error/ErrorDialog.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { useSearchParams } from "react-router-dom"; import { AlertCircleIcon } from "lucide-react"; import { Button } from "@/components/buttons/Button"; +import { CopyToClipboard } from "@/components/CopyToClipboard"; import { ConfirmDialog } from "@/components/dialog/ConfirmDialog"; import { DialogActions } from "@/components/dialog/DialogActions"; import { DialogDescription } from "@/components/dialog/DialogDescription"; @@ -12,14 +13,22 @@ import { WindowManager } from "@bindings/services"; import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; const WINDOW_WIDTH = 380; +// A command needs the room to wrap at a sensible number of characters instead of +// breaking every few words. +const WINDOW_WIDTH_WITH_COMMAND = 460; export default function ErrorDialog() { const { t } = useTranslation(); - const contentRef = useAutoSizeWindow(WINDOW_WIDTH); const [params] = useSearchParams(); const title = params.get("title") || t("window.title.error"); const message = params.get("message") || ""; + // Set when the daemon refused an operation that needs elevated privileges: + // the command that performs it, offered for copying. + const command = params.get("command") || ""; + const contentRef = useAutoSizeWindow( + command ? WINDOW_WIDTH_WITH_COMMAND : WINDOW_WIDTH, + ); const close = useCallback(() => { WindowManager.CloseError().catch(console.error); @@ -37,15 +46,37 @@ export default function ErrorDialog() { -
+
{title} {message && ( - {message} + {/* select-text: the message often names a path, a flag or an + address the user needs to act on. */} + + {message} + )} + {command && ( + + + {command} + + + )}
diff --git a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx index c2a648278..2f4014741 100644 --- a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx +++ b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx @@ -20,6 +20,7 @@ import { useFocusVisible } from "@/hooks/useFocusVisible"; import { Check as CheckIcon, ChevronDownIcon, Copy as CopyIcon } from "lucide-react"; import * as Popover from "@radix-ui/react-popover"; import netbirdFullLogo from "@/assets/logos/netbird-full.svg"; +import netbirdFullLogoLight from "@/assets/logos/netbird-full-light.svg"; enum ConnectionState { Disconnected = "disconnected", @@ -224,10 +225,16 @@ export const MainConnectionStatusSwitch = () => { className={cn("flex h-full w-full flex-col items-center gap-4", "relative")} style={{ top: contentTop("11.7rem") }} > + {"NetBird"} @@ -321,7 +328,7 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole className={cn( "group relative inline-flex cursor-default items-center rounded-sm outline-none", isFocusVisible && - "focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", + "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", "transition-colors", )} > @@ -395,7 +402,7 @@ const IpRow = ({ value }: { value: string }) => { "text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50", "cursor-default outline-none transition-colors", isFocusVisible && - "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60", + "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60", )} > {value} diff --git a/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx b/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx index fc7ce37b2..0d6579dd7 100644 --- a/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx +++ b/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx @@ -156,14 +156,14 @@ const ExitNodeTriggerCard = forwardRef( disabled={disabled} className={cn( "flex w-full items-center gap-3 rounded-xl p-2.5 pr-5 text-left outline-none", - "border border-nb-gray-920 bg-nb-gray-940", + "border border-nb-gray-800 bg-nb-gray-940 dark:border-nb-gray-920", "transition-colors duration-150", "wails-no-draggable", isFocusVisible && - "focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", + "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", disabled ? "cursor-not-allowed opacity-60" - : "cursor-default hover:border-nb-gray-900 hover:bg-nb-gray-935 data-[state=open]:border-nb-gray-900 data-[state=open]:bg-nb-gray-935", + : "cursor-default hover:border-nb-gray-700 hover:bg-nb-gray-935 data-[state=open]:border-nb-gray-700 data-[state=open]:bg-nb-gray-935 dark:hover:border-nb-gray-900 dark:data-[state=open]:border-nb-gray-900", className, )} {...props} diff --git a/client/ui/frontend/src/modules/main/MainHeader.tsx b/client/ui/frontend/src/modules/main/MainHeader.tsx index becfde47c..d79e76cd1 100644 --- a/client/ui/frontend/src/modules/main/MainHeader.tsx +++ b/client/ui/frontend/src/modules/main/MainHeader.tsx @@ -74,7 +74,9 @@ export const MainHeader = () => { { "outline-none transition-all", isFirst && "rounded-tl-xl", isLast && "rounded-tr-xl", - "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60", + "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60", isActive ? "text-netbird" : "text-nb-gray-400 hover:text-nb-gray-300", isDisabled ? "cursor-not-allowed opacity-50" : "cursor-default", )} diff --git a/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx b/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx index ec1823f0b..0d8c9f4db 100644 --- a/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx +++ b/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx @@ -43,7 +43,7 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) => "inline-flex h-9 items-center gap-1.5 rounded-md px-2", "text-sm text-nb-gray-200", "outline-none transition-colors duration-150 hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900", - "focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", + "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", "disabled:pointer-events-none disabled:opacity-50", "wails-no-draggable cursor-default", )} diff --git a/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx index f6fe23aaf..a13993aa9 100644 --- a/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx +++ b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx @@ -223,7 +223,7 @@ export const Networks = () => { "text-xs font-medium text-nb-gray-100", "border border-nb-gray-900 bg-nb-gray-920 hover:border-nb-gray-850 hover:bg-nb-gray-910", "wails-no-draggable cursor-pointer outline-none transition-colors", - "focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", + "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", )} > {bulkLabel} @@ -358,7 +358,7 @@ const NetworkRow = ({ network: n, index, onKeyDown, onToggle, setRowRef }: Netwo onKeyDown={handleKey} className={cn( "absolute inset-0 cursor-pointer outline-none", - "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60", + "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60", )} /> @@ -396,7 +396,8 @@ const ResourceIconBadge = ({ type }: { type: ResourceType }) => { aria-hidden={"true"} className={cn( "mt-[0.25rem] flex h-9 w-9 shrink-0 items-center justify-center rounded-md", - "border border-nb-gray-900 bg-nb-gray-920 text-nb-gray-300", + "border border-nb-gray-800 bg-white text-nb-gray-300 transition-colors group-hover:border-nb-gray-700", + "dark:border-nb-gray-900 dark:bg-nb-gray-920 dark:group-hover:border-nb-gray-900", )} > @@ -461,7 +462,7 @@ const DomainSubtitle = ({ domain, ips, onKeyDown }: DomainSubtitleProps) => { keepOpenOnClick contentClassName={cn( "max-h-72 max-w-[18rem] overflow-auto", - "rounded-lg border border-nb-gray-900 bg-nb-gray-935", + "rounded-lg border border-nb-gray-800 bg-white dark:border-nb-gray-900 dark:bg-nb-gray-935", "p-2 pr-4", )} > diff --git a/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx b/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx index 16500589d..ee3de6643 100644 --- a/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx +++ b/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx @@ -54,9 +54,9 @@ const DASH = "-"; const dotClass = (connStatus: string): string => { switch (connStatus) { case "Connected": - return "bg-green-400"; + return "bg-green-500 dark:bg-green-400"; case "Connecting": - return "bg-yellow-300 animate-pulse-slow"; + return "bg-yellow-500 animate-pulse-slow dark:bg-yellow-300"; default: return "bg-nb-gray-500"; } @@ -195,7 +195,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => { "flex h-8 w-8 shrink-0 items-center justify-center rounded-md", "text-nb-gray-300 hover:bg-nb-gray-910 hover:text-nb-gray-100", "cursor-default outline-none transition-colors", - "focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", + "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", "wails-no-draggable", )} > @@ -235,7 +235,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => { "flex h-8 w-8 shrink-0 items-center justify-center rounded-md", "text-nb-gray-300 hover:bg-nb-gray-910 hover:text-nb-gray-100", "cursor-default outline-none transition-colors", - "focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", + "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", "wails-no-draggable", "disabled:opacity-50 disabled:hover:bg-transparent", )} @@ -468,7 +468,7 @@ const ResourcesPopover = ({ networks }: { networks: string[] }) => { "border border-nb-gray-900", "py-1 pl-2.5 pr-2 text-xs font-medium text-nb-gray-300", "wails-no-draggable cursor-default outline-none transition-all", - "focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", + "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", )} > {networks.length} @@ -530,7 +530,7 @@ const ResourceRow = ({ value }: { value: string }) => { "text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50", "cursor-default outline-none transition-colors", isFocusVisible && - "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60", + "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60", )} > {value} diff --git a/client/ui/frontend/src/modules/main/advanced/peers/PeerFilters.tsx b/client/ui/frontend/src/modules/main/advanced/peers/PeerFilters.tsx index 25d9ef822..51dd5b04e 100644 --- a/client/ui/frontend/src/modules/main/advanced/peers/PeerFilters.tsx +++ b/client/ui/frontend/src/modules/main/advanced/peers/PeerFilters.tsx @@ -43,7 +43,7 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => { "inline-flex h-9 items-center gap-1.5 rounded-md px-2", "text-sm text-nb-gray-200", "outline-none transition-colors duration-150 hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900", - "focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", + "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", "disabled:pointer-events-none disabled:opacity-50", "wails-no-draggable cursor-default", )} diff --git a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx index d61221758..f3cb0f139 100644 --- a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx +++ b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx @@ -22,9 +22,9 @@ const isOnline = (connStatus: string) => connStatus === "Connected"; const dotClass = (connStatus: string): string => { switch (connStatus) { case "Connected": - return "bg-green-400"; + return "bg-green-500 dark:bg-green-400"; case "Connecting": - return "bg-yellow-300 animate-pulse-slow"; + return "bg-yellow-500 animate-pulse-slow dark:bg-yellow-300"; default: return "bg-nb-gray-500"; } @@ -287,7 +287,7 @@ const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps) onKeyDown={handleKey} className={cn( "absolute inset-0 cursor-default outline-none", - "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60", + "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60", )} /> diff --git a/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx b/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx index e76e300c1..81da3aa29 100644 --- a/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx +++ b/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx @@ -92,7 +92,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => { listRef.current?.focus(); }} className={cn( - "wails-no-draggable z-50 min-w-64 select-none overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg", + "wails-no-draggable z-50 min-w-64 select-none overflow-hidden rounded-lg border border-nb-gray-800 bg-nb-gray-950 p-1 text-nb-gray-200 shadow-lg dark:border-nb-gray-900 dark:bg-nb-gray-935", "data-[state=open]:animate-in data-[state=closed]:animate-out", "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", @@ -211,11 +211,11 @@ const ProfileTriggerButton = forwardRef switchProfile(id)); + await guarded(i18next.t("profile.error.switchTitle"), () => switchProfileNoConnect(id)); }; const handleDeregister = async (id: string, name: string) => { @@ -129,14 +129,13 @@ export function ProfilesTab() { await guarded(i18next.t("profile.error.createTitle"), async () => { const id = await addProfile(name); // SetConfig is keyed by the new profile's ID, so it writes the - // not-yet-active profile. Write before switching so any reconnect - // targets the right deployment. + // not-yet-active profile before the switch makes it current. if (!isNetbirdCloud(managementUrl)) { await SettingsSvc.SetConfig( new SetConfigParams({ profileName: id, username, managementUrl }), ); } - await switchProfile(id); + await switchProfileNoConnect(id); }); }; @@ -178,7 +177,7 @@ export function ProfilesTab() {
@@ -655,7 +654,7 @@ const ActionIconButton = ({ className={cn( "inline-flex h-9 w-9 cursor-default items-center justify-center rounded-md outline-none", "transition-colors duration-150", - "focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", + "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", variant === "danger" ? "text-nb-gray-400 hover:bg-red-500/10 hover:text-red-500" : "text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-100", diff --git a/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx b/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx index 2ceb958d4..e57040a7a 100644 --- a/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx +++ b/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx @@ -11,13 +11,18 @@ import { DialogHeading } from "@/components/dialog/DialogHeading"; import { SquareIcon } from "@/components/SquareIcon"; import { Connection, Profiles as ProfilesSvc, Session, WindowManager } from "@bindings/services"; import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; -import { EVENT_BROWSER_LOGIN_CANCEL } from "@/lib/connection"; +import { EVENT_BROWSER_LOGIN_CANCEL, EVENT_TRIGGER_LOGIN } from "@/lib/connection"; import { errorDialog, formatErrorMessage } from "@/lib/errors.ts"; import { formatRemaining } from "@/lib/formatters"; const DEFAULT_SECONDS = 360; const WINDOW_WIDTH = 360; const SOON_THRESHOLD_SECONDS = 60 * 60; +const DEADLINE_TOLERANCE_MS = 5 * 1000; +// The final-warning deadline reaches the Go side as RFC3339 truncated to whole +// seconds, while the status snapshot carries millisecond precision, so an +// unchanged deadline can look up to 999 ms newer than the exact URL value. +const EXACT_DEADLINE_TOLERANCE_MS = 999; export default function SessionExpirationDialog() { const { t } = useTranslation(); @@ -29,11 +34,19 @@ export default function SessionExpirationDialog() { const n = Number.parseInt(raw, 10); return Number.isFinite(n) && n > 0 ? n : DEFAULT_SECONDS; }, [params]); + const initialDeadline = useMemo(() => { + const raw = params.get("deadline"); + if (!raw) return null; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : null; + }, [params]); const [remaining, setRemaining] = useState(initialSeconds); const [busy, setBusy] = useState(false); const busyRef = useRef(busy); busyRef.current = busy; + const openedDeadlineRef = useRef(initialDeadline ?? Date.now() + initialSeconds * 1000); + const exactDeadlineRef = useRef(initialDeadline !== null); const expired = remaining <= 0; const expiredRef = useRef(expired); expiredRef.current = expired; @@ -45,23 +58,45 @@ export default function SessionExpirationDialog() { useEffect(() => { setRemaining(initialSeconds); - }, [initialSeconds]); + openedDeadlineRef.current = initialDeadline ?? Date.now() + initialSeconds * 1000; + exactDeadlineRef.current = initialDeadline !== null; + }, [initialSeconds, initialDeadline]); + // Recompute from the absolute deadline instead of decrementing per tick: webview + // timers get suspended for tens of seconds (App Nap / hidden-window throttling), + // so a tick counter drifts behind the wall clock by the suspended time. useEffect(() => { const id = globalThis.setInterval(() => { - setRemaining((s) => (s <= 1 ? 0 : s - 1)); + setRemaining(Math.max(0, Math.ceil((openedDeadlineRef.current - Date.now()) / 1000))); }, 1000); return () => globalThis.clearInterval(id); }, [initialSeconds]); + // Auto-close only when the session was actually renewed elsewhere (tray action, CLI, + // main window): the daemon keeps emitting Connected snapshots regardless of session + // state, so the signal is the deadline jumping past the one this dialog was opened for. + // With the exact deadline from the URL any jump past its sub-second precision loss + // counts; the seconds-derived fallback needs a wider tolerance for the Go-side + // truncation and mount latency. // Don't auto-close while busy (aborts our WaitExtend) or expired (hides the state). useEffect(() => { - const off = Events.On("netbird:status", (ev: { data: { status?: string } }) => { - if (busyRef.current || expiredRef.current) return; - if (ev?.data?.status === "Connected") { - WindowManager.CloseSessionExpiration().catch(console.error); - } - }); + const off = Events.On( + "netbird:status", + (ev: { data: { status?: string; sessionExpiresAt?: string | null } }) => { + if (busyRef.current || expiredRef.current) return; + if (ev?.data?.status !== "Connected") return; + const raw = ev?.data?.sessionExpiresAt; + if (!raw) return; + const renewed = Date.parse(raw); + if (!Number.isFinite(renewed)) return; + const tolerance = exactDeadlineRef.current + ? EXACT_DEADLINE_TOLERANCE_MS + : DEADLINE_TOLERANCE_MS; + if (renewed - openedDeadlineRef.current > tolerance) { + WindowManager.CloseSessionExpiration().catch(console.error); + } + }, + ); return () => { off(); }; @@ -73,6 +108,13 @@ export default function SessionExpirationDialog() { let offCancel: (() => void) | undefined; + // Return the dialog to its interactive state and dismiss the browser popup + const resetDialog = () => { + offCancel?.(); + WindowManager.CloseBrowserLogin().catch(console.error); + setBusy(false); + }; + try { const start = await Session.RequestExtend({ hint: "" }); const uri = start.verificationUriComplete || start.verificationUri; @@ -105,25 +147,37 @@ export default function SessionExpirationDialog() { if (outcome.kind === "cancel") { waitPromise.cancel?.(); waitPromise.catch(() => {}); + resetDialog(); return; } // Another surface owns this flow; keep the dialog open to retry. if (outcome.result.preempted) { + resetDialog(); return; } - - // Close before the popup so the restore can't flash this window back. - WindowManager.CloseSessionExpiration().catch(console.error); + WindowManager.CloseRenewFlow().catch(console.error); } catch (e) { + resetDialog(); await errorDialog({ Title: t("sessionExpiration.extendFailedTitle"), Message: formatErrorMessage(e), }); - } finally { - offCancel?.(); - WindowManager.CloseBrowserLogin().catch(console.error); + } + }, [busy, t]); + + const authenticate = useCallback(async () => { + if (busy) return; + setBusy(true); + try { + await Events.Emit(EVENT_TRIGGER_LOGIN); + await WindowManager.CloseSessionExpiration(); + } catch (e) { setBusy(false); + await errorDialog({ + Title: t("connect.error.loginTitle"), + Message: formatErrorMessage(e), + }); } }, [busy, t]); @@ -139,12 +193,11 @@ export default function SessionExpirationDialog() { }); WindowManager.CloseSessionExpiration().catch(console.error); } catch (e) { + setBusy(false); await errorDialog({ Title: t("sessionExpiration.logoutFailedTitle"), Message: formatErrorMessage(e), }); - } finally { - setBusy(false); } }, [busy, t]); @@ -182,7 +235,7 @@ export default function SessionExpirationDialog() { variant={"primary"} size={"md"} className={"w-full"} - onClick={stay} + onClick={expired ? authenticate : stay} disabled={busy} > {expired ? t("sessionExpiration.authenticate") : t("sessionExpiration.stay")} diff --git a/client/ui/frontend/src/modules/settings/SettingsAbout.tsx b/client/ui/frontend/src/modules/settings/SettingsAbout.tsx index 8ba1009ba..c103d1d74 100644 --- a/client/ui/frontend/src/modules/settings/SettingsAbout.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsAbout.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { Browser } from "@wailsio/runtime"; import { BookOpen, MessageSquareText, MessagesSquare } from "lucide-react"; import netbirdFull from "@/assets/logos/netbird-full.svg"; +import netbirdFullLight from "@/assets/logos/netbird-full-light.svg"; // Brand glyphs from simpleicons.org (lucide deprecated its brand icons). const GithubIcon = (props: SVGProps) => ( @@ -90,7 +91,16 @@ export function SettingsAbout() { "mx-auto flex min-h-[calc(100vh-12rem)] max-w-2xl flex-col items-center justify-center gap-4" } > - {t("common.netbird")} + {t("common.netbird")} + {t("common.netbird")}
+ + + setAnonymizeLevel(v as AnonymizeLevel)} + > + + {t("settings.troubleshooting.anonymize.none")} + + + {t("settings.troubleshooting.anonymize.default")} + + + {t("settings.troubleshooting.anonymize.strict")} + + + + +
+
- +
@@ -258,7 +329,9 @@ function DoneResult({