diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..a546f5f5e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.env +.env.* +*.pem +*.key +*.crt +*.p12 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..e9ffaf8a3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,14 @@ +blank_issues_enabled: true +contact_links: + - name: Community Support + url: https://forum.netbird.io/ + about: Community support forum + - name: Cloud Support + url: https://docs.netbird.io/help/report-bug-issues + about: Contact us for support + - name: Client/Connection Troubleshooting + url: https://docs.netbird.io/help/troubleshooting-client + about: See our client troubleshooting guide for help addressing common issues + - name: Self-host Troubleshooting + url: https://docs.netbird.io/selfhosted/troubleshooting + about: See our self-host troubleshooting guide for help addressing common issues diff --git a/.github/workflows/check-license-dependencies.yml b/.github/workflows/check-license-dependencies.yml index 543ba2ab2..d1d2a8e50 100644 --- a/.github/workflows/check-license-dependencies.yml +++ b/.github/workflows/check-license-dependencies.yml @@ -23,7 +23,7 @@ jobs: - name: Check for problematic license dependencies run: | - echo "Checking for dependencies on management/, signal/, and relay/ packages..." + echo "Checking for dependencies on management/, signal/, relay/, and proxy/ packages..." echo "" # Find all directories except the problematic ones and system dirs @@ -31,7 +31,7 @@ jobs: while IFS= read -r dir; do echo "=== Checking $dir ===" # Search for problematic imports, excluding test files - RESULTS=$(grep -r "github.com/netbirdio/netbird/\(management\|signal\|relay\)" "$dir" --include="*.go" 2>/dev/null | grep -v "_test.go" | grep -v "test_" | grep -v "/test/" || true) + RESULTS=$(grep -r "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\)" "$dir" --include="*.go" 2>/dev/null | grep -v "_test.go" | grep -v "test_" | grep -v "/test/" || true) if [ -n "$RESULTS" ]; then echo "❌ Found problematic dependencies:" echo "$RESULTS" @@ -39,11 +39,11 @@ jobs: else echo "✓ No problematic dependencies found" fi - done < <(find . -maxdepth 1 -type d -not -name "." -not -name "management" -not -name "signal" -not -name "relay" -not -name ".git*" | sort) + done < <(find . -maxdepth 1 -type d -not -name "." -not -name "management" -not -name "signal" -not -name "relay" -not -name "proxy" -not -name "combined" -not -name ".git*" | sort) echo "" if [ $FOUND_ISSUES -eq 1 ]; then - echo "❌ Found dependencies on management/, signal/, or relay/ packages" + echo "❌ Found dependencies on management/, signal/, relay/, or proxy/ packages" echo "These packages are licensed under AGPLv3 and must not be imported by BSD-licensed code" exit 1 else @@ -88,7 +88,7 @@ jobs: IMPORTERS=$(go list -json -deps ./... 2>/dev/null | jq -r "select(.Imports[]? == \"$package\") | .ImportPath") # Check if any importer is NOT in management/signal/relay - BSD_IMPORTER=$(echo "$IMPORTERS" | grep -v "github.com/netbirdio/netbird/\(management\|signal\|relay\)" | head -1) + BSD_IMPORTER=$(echo "$IMPORTERS" | grep -v "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\|combined\)" | head -1) if [ -n "$BSD_IMPORTER" ]; then echo "❌ $package ($license) is imported by BSD-licensed code: $BSD_IMPORTER" diff --git a/.github/workflows/golang-test-darwin.yml b/.github/workflows/golang-test-darwin.yml index 9c4c35d21..0528ed086 100644 --- a/.github/workflows/golang-test-darwin.yml +++ b/.github/workflows/golang-test-darwin.yml @@ -43,5 +43,5 @@ jobs: run: git --no-pager diff --exit-code - name: Test - run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list ./... | grep -v /management) + run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined) diff --git a/.github/workflows/golang-test-freebsd.yml b/.github/workflows/golang-test-freebsd.yml index df64e86bb..2c029b117 100644 --- a/.github/workflows/golang-test-freebsd.yml +++ b/.github/workflows/golang-test-freebsd.yml @@ -46,6 +46,5 @@ jobs: time go test -timeout 1m -failfast ./client/iface/... time go test -timeout 1m -failfast ./route/... time go test -timeout 1m -failfast ./sharedsock/... - time go test -timeout 1m -failfast ./signal/... time go test -timeout 1m -failfast ./util/... time go test -timeout 1m -failfast ./version/... diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml index 195a37a1f..450c44aea 100644 --- a/.github/workflows/golang-test-linux.yml +++ b/.github/workflows/golang-test-linux.yml @@ -97,6 +97,16 @@ jobs: working-directory: relay run: CGO_ENABLED=1 GOARCH=386 go build -o relay-386 . + - name: Build combined + if: steps.cache.outputs.cache-hit != 'true' + working-directory: combined + run: CGO_ENABLED=1 go build . + + - name: Build combined 386 + if: steps.cache.outputs.cache-hit != 'true' + working-directory: combined + run: CGO_ENABLED=1 GOARCH=386 go build -o combined-386 . + test: name: "Client / Unit" needs: [build-cache] @@ -144,7 +154,7 @@ jobs: run: git --no-pager diff --exit-code - name: Test - run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay) + run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined) test_client_on_docker: name: "Client (Docker) / Unit" @@ -204,7 +214,7 @@ jobs: sh -c ' \ apk update; apk add --no-cache \ ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \ - go test -buildvcs=false -tags devcert -v -timeout 10m -p 1 $(go list -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /client/ui -e /upload-server) + go test -buildvcs=false -tags devcert -v -timeout 10m -p 1 $(go list -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server) ' test_relay: @@ -261,6 +271,53 @@ jobs: -exec 'sudo' \ -timeout 10m -p 1 ./relay/... ./shared/relay/... + test_proxy: + name: "Proxy / Unit" + needs: [build-cache] + strategy: + fail-fast: false + matrix: + arch: [ '386','amd64' ] + runs-on: ubuntu-22.04 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version-file: "go.mod" + cache: false + + - name: Install dependencies + run: sudo apt update && sudo apt install -y gcc-multilib g++-multilib libc6-dev-i386 + + - name: Get Go environment + run: | + echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV + echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV + + - name: Cache Go modules + uses: actions/cache/restore@v4 + with: + path: | + ${{ env.cache }} + ${{ env.modcache }} + key: ${{ runner.os }}-gotest-cache-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-gotest-cache- + + - name: Install modules + run: go mod tidy + + - name: check git status + run: git --no-pager diff --exit-code + + - name: Test + run: | + CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \ + go test -timeout 10m -p 1 ./proxy/... + test_signal: name: "Signal / Unit" needs: [build-cache] @@ -352,12 +409,19 @@ jobs: run: git --no-pager diff --exit-code - name: Login to Docker hub - if: matrix.store == 'mysql' && (github.repository == github.head.repo.full_name || !github.head_ref) - uses: docker/login-action@v1 + if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_TOKEN }} + - name: docker login for root user + if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref + env: + DOCKER_USER: ${{ secrets.DOCKER_USER }} + DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }} + run: echo "$DOCKER_TOKEN" | sudo docker login --username "$DOCKER_USER" --password-stdin + - name: download mysql image if: matrix.store == 'mysql' run: docker pull mlsmaycon/warmed-mysql:8 @@ -440,15 +504,18 @@ jobs: run: git --no-pager diff --exit-code - name: Login to Docker hub - if: matrix.store == 'mysql' && (github.repository == github.head.repo.full_name || !github.head_ref) - uses: docker/login-action@v1 + if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_TOKEN }} - - name: download mysql image - if: matrix.store == 'mysql' - run: docker pull mlsmaycon/warmed-mysql:8 + - name: docker login for root user + if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref + env: + DOCKER_USER: ${{ secrets.DOCKER_USER }} + DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }} + run: echo "$DOCKER_TOKEN" | sudo docker login --username "$DOCKER_USER" --password-stdin - name: Test run: | @@ -529,15 +596,18 @@ jobs: run: git --no-pager diff --exit-code - name: Login to Docker hub - if: matrix.store == 'mysql' && (github.repository == github.head.repo.full_name || !github.head_ref) - uses: docker/login-action@v1 + if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_TOKEN }} - - name: download mysql image - if: matrix.store == 'mysql' - run: docker pull mlsmaycon/warmed-mysql:8 + - name: docker login for root user + if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref + env: + DOCKER_USER: ${{ secrets.DOCKER_USER }} + DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }} + run: echo "$DOCKER_TOKEN" | sudo docker login --username "$DOCKER_USER" --password-stdin - name: Test run: | diff --git a/.github/workflows/golang-test-windows.yml b/.github/workflows/golang-test-windows.yml index 43357c45f..8af4046a7 100644 --- a/.github/workflows/golang-test-windows.yml +++ b/.github/workflows/golang-test-windows.yml @@ -63,7 +63,7 @@ jobs: - run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOMODCACHE=${{ env.cache }} - run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOCACHE=${{ env.modcache }} - run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe mod tidy - - run: echo "files=$(go list ./... | ForEach-Object { $_ } | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' })" >> $env:GITHUB_ENV + - run: echo "files=$(go list ./... | ForEach-Object { $_ } | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' })" >> $env:GITHUB_ENV - name: test run: PsExec64 -s -w ${{ github.workspace }} cmd.exe /c "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe test -tags=devcert -timeout 10m -p 1 ${{ env.files }} > test-out.txt 2>&1" diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 19a3a01e0..9e753ce73 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -19,8 +19,8 @@ jobs: - name: codespell uses: codespell-project/actions-codespell@v2 with: - ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans - skip: go.mod,go.sum + ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te + skip: go.mod,go.sum,**/proxy/web/** golangci: strategy: fail-fast: false diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml new file mode 100644 index 000000000..a2e6ce219 --- /dev/null +++ b/.github/workflows/pr-title-check.yml @@ -0,0 +1,51 @@ +name: PR Title Check + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +jobs: + check-title: + runs-on: ubuntu-latest + steps: + - name: Validate PR title prefix + uses: actions/github-script@v7 + with: + script: | + const title = context.payload.pull_request.title; + const allowedTags = [ + 'management', + 'client', + 'signal', + 'proxy', + 'relay', + 'misc', + 'infrastructure', + 'self-hosted', + 'doc', + ]; + + const pattern = /^\[([^\]]+)\]\s+.+/; + const match = title.match(pattern); + + if (!match) { + core.setFailed( + `PR title must start with a tag in brackets.\n` + + `Example: [client] fix something\n` + + `Allowed tags: ${allowedTags.join(', ')}` + ); + return; + } + + const tags = match[1].split(',').map(t => t.trim().toLowerCase()); + + const invalid = tags.filter(t => !allowedTags.includes(t)); + if (invalid.length > 0) { + core.setFailed( + `Invalid tag(s): ${invalid.join(', ')}\n` + + `Allowed tags: ${allowedTags.join(', ')}` + ); + return; + } + + console.log(`Valid PR title tags: [${tags.join(', ')}]`); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 84f6f64ed..1a4676625 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,8 +9,8 @@ on: pull_request: env: - SIGN_PIPE_VER: "v0.1.0" - GORELEASER_VER: "v2.3.2" + SIGN_PIPE_VER: "v0.1.1" + GORELEASER_VER: "v2.14.3" PRODUCT_NAME: "NetBird" COPYRIGHT: "NetBird GmbH" @@ -160,7 +160,7 @@ jobs: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_TOKEN }} - name: Log in to the GitHub container registry - if: github.event_name != 'pull_request' + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository uses: docker/login-action@v3 with: registry: ghcr.io @@ -169,6 +169,13 @@ jobs: - name: Install OS build dependencies run: sudo apt update && sudo apt install -y -q gcc-arm-linux-gnueabihf gcc-aarch64-linux-gnu + - name: Decode GPG signing key + env: + GPG_RPM_PRIVATE_KEY: ${{ secrets.GPG_RPM_PRIVATE_KEY }} + run: | + echo "$GPG_RPM_PRIVATE_KEY" | base64 -d > /tmp/gpg-rpm-signing-key.asc + echo "GPG_RPM_KEY_FILE=/tmp/gpg-rpm-signing-key.asc" >> $GITHUB_ENV + - name: Install goversioninfo run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e - name: Generate windows syso amd64 @@ -176,6 +183,7 @@ jobs: - name: Generate windows syso arm64 run: goversioninfo -arm -64 -icon client/ui/assets/netbird.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_arm64.syso - name: Run GoReleaser + id: goreleaser uses: goreleaser/goreleaser-action@v4 with: version: ${{ env.GORELEASER_VER }} @@ -185,6 +193,55 @@ jobs: HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} UPLOAD_DEBIAN_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }} UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }} + GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }} + NFPM_NETBIRD_RPM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }} + - name: Verify RPM signatures + run: | + docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c ' + dnf install -y -q rpm-sign curl >/dev/null 2>&1 + curl -sSL https://pkgs.netbird.io/yum/repodata/repomd.xml.key -o /tmp/rpm-pub.key + rpm --import /tmp/rpm-pub.key + echo "=== Verifying RPM signatures ===" + for rpm_file in /dist/*amd64*.rpm; do + [ -f "$rpm_file" ] || continue + echo "--- $(basename $rpm_file) ---" + rpm -K "$rpm_file" + done + ' + - name: Clean up GPG key + if: always() + run: rm -f /tmp/gpg-rpm-signing-key.asc + - name: Tag and push images (amd64 only) + if: | + (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || + (github.event_name == 'push' && github.ref == 'refs/heads/main') + run: | + resolve_tags() { + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + echo "pr-${{ github.event.pull_request.number }}" + else + echo "main sha-$(git rev-parse --short HEAD)" + fi + } + + tag_and_push() { + local src="$1" img_name tag dst + img_name="${src%%:*}" + for tag in $(resolve_tags); do + dst="${img_name}:${tag}" + echo "Tagging ${src} -> ${dst}" + docker tag "$src" "$dst" + docker push "$dst" + done + } + + export -f tag_and_push resolve_tags + + echo '${{ steps.goreleaser.outputs.artifacts }}' | \ + jq -r '.[] | select(.type == "Docker Image") | select(.goarch == "amd64") | .name' | \ + grep '^ghcr.io/' | while read -r SRC; do + tag_and_push "$SRC" + done - name: upload non tags for debug purposes uses: actions/upload-artifact@v4 with: @@ -251,6 +308,13 @@ jobs: - name: Install dependencies run: sudo apt update && sudo apt install -y -q libappindicator3-dev gir1.2-appindicator3-0.1 libxxf86vm-dev gcc-mingw-w64-x86-64 + - name: Decode GPG signing key + env: + GPG_RPM_PRIVATE_KEY: ${{ secrets.GPG_RPM_PRIVATE_KEY }} + run: | + echo "$GPG_RPM_PRIVATE_KEY" | base64 -d > /tmp/gpg-rpm-signing-key.asc + echo "GPG_RPM_KEY_FILE=/tmp/gpg-rpm-signing-key.asc" >> $GITHUB_ENV + - name: Install LLVM-MinGW for ARM64 cross-compilation run: | cd /tmp @@ -275,6 +339,24 @@ jobs: HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} UPLOAD_DEBIAN_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }} UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }} + GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }} + NFPM_NETBIRD_UI_RPM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }} + - name: Verify RPM signatures + run: | + docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c ' + dnf install -y -q rpm-sign curl >/dev/null 2>&1 + curl -sSL https://pkgs.netbird.io/yum/repodata/repomd.xml.key -o /tmp/rpm-pub.key + rpm --import /tmp/rpm-pub.key + echo "=== Verifying RPM signatures ===" + for rpm_file in /dist/*.rpm; do + [ -f "$rpm_file" ] || continue + echo "--- $(basename $rpm_file) ---" + rpm -K "$rpm_file" + done + ' + - name: Clean up GPG key + if: always() + run: rm -f /tmp/gpg-rpm-signing-key.asc - name: upload non tags for debug purposes uses: actions/upload-artifact@v4 with: diff --git a/.gitignore b/.gitignore index 89024d190..a0f128933 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .run *.iml dist/ +!proxy/web/dist/ bin/ .env conf.json diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 7c6651f83..0f81229cd 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -106,6 +106,26 @@ builds: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser mod_timestamp: "{{ .CommitTimestamp }}" + - id: netbird-server + dir: combined + env: + - CGO_ENABLED=1 + - >- + {{- if eq .Runtime.Goos "linux" }} + {{- if eq .Arch "arm64"}}CC=aarch64-linux-gnu-gcc{{- end }} + {{- if eq .Arch "arm"}}CC=arm-linux-gnueabihf-gcc{{- end }} + {{- end }} + binary: netbird-server + goos: + - linux + goarch: + - amd64 + - arm64 + - arm + ldflags: + - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser + mod_timestamp: "{{ .CommitTimestamp }}" + - id: netbird-upload dir: upload-server env: [CGO_ENABLED=0] @@ -120,6 +140,20 @@ builds: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser mod_timestamp: "{{ .CommitTimestamp }}" + - id: netbird-proxy + dir: proxy/cmd/proxy + env: [CGO_ENABLED=0] + binary: netbird-proxy + goos: + - linux + goarch: + - amd64 + - arm64 + - arm + ldflags: + - -s -w -X main.Version={{.Version}} -X main.Commit={{.Commit}} -X main.BuildDate={{.CommitDate}} + mod_timestamp: "{{ .CommitTimestamp }}" + universal_binaries: - id: netbird @@ -137,13 +171,12 @@ nfpms: - maintainer: Netbird description: Netbird client. homepage: https://netbird.io/ - id: netbird-deb + id: netbird_deb bindir: /usr/bin builds: - netbird formats: - deb - scripts: postinstall: "release_files/post_install.sh" preremove: "release_files/pre_remove.sh" @@ -151,16 +184,18 @@ nfpms: - maintainer: Netbird description: Netbird client. homepage: https://netbird.io/ - id: netbird-rpm + id: netbird_rpm bindir: /usr/bin builds: - netbird formats: - rpm - scripts: postinstall: "release_files/post_install.sh" preremove: "release_files/pre_remove.sh" + rpm: + signature: + key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}' dockers: - image_templates: - netbirdio/netbird:{{ .Version }}-amd64 @@ -520,6 +555,104 @@ dockers: - "--label=org.opencontainers.image.revision={{.FullCommit}}" - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - "--label=maintainer=dev@netbird.io" + - image_templates: + - netbirdio/netbird-server:{{ .Version }}-amd64 + - ghcr.io/netbirdio/netbird-server:{{ .Version }}-amd64 + ids: + - netbird-server + goarch: amd64 + use: buildx + dockerfile: combined/Dockerfile + build_flag_templates: + - "--platform=linux/amd64" + - "--label=org.opencontainers.image.created={{.Date}}" + - "--label=org.opencontainers.image.title={{.ProjectName}}" + - "--label=org.opencontainers.image.version={{.Version}}" + - "--label=org.opencontainers.image.revision={{.FullCommit}}" + - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" + - "--label=maintainer=dev@netbird.io" + - image_templates: + - netbirdio/netbird-server:{{ .Version }}-arm64v8 + - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm64v8 + ids: + - netbird-server + goarch: arm64 + use: buildx + dockerfile: combined/Dockerfile + build_flag_templates: + - "--platform=linux/arm64" + - "--label=org.opencontainers.image.created={{.Date}}" + - "--label=org.opencontainers.image.title={{.ProjectName}}" + - "--label=org.opencontainers.image.version={{.Version}}" + - "--label=org.opencontainers.image.revision={{.FullCommit}}" + - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" + - "--label=maintainer=dev@netbird.io" + - image_templates: + - netbirdio/netbird-server:{{ .Version }}-arm + - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm + ids: + - netbird-server + goarch: arm + goarm: 6 + use: buildx + dockerfile: combined/Dockerfile + build_flag_templates: + - "--platform=linux/arm" + - "--label=org.opencontainers.image.created={{.Date}}" + - "--label=org.opencontainers.image.title={{.ProjectName}}" + - "--label=org.opencontainers.image.version={{.Version}}" + - "--label=org.opencontainers.image.revision={{.FullCommit}}" + - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" + - "--label=maintainer=dev@netbird.io" + - image_templates: + - netbirdio/reverse-proxy:{{ .Version }}-amd64 + - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-amd64 + ids: + - netbird-proxy + goarch: amd64 + use: buildx + dockerfile: proxy/Dockerfile + build_flag_templates: + - "--platform=linux/amd64" + - "--label=org.opencontainers.image.created={{.Date}}" + - "--label=org.opencontainers.image.title={{.ProjectName}}" + - "--label=org.opencontainers.image.version={{.Version}}" + - "--label=org.opencontainers.image.revision={{.FullCommit}}" + - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" + - "--label=maintainer=dev@netbird.io" + - image_templates: + - netbirdio/reverse-proxy:{{ .Version }}-arm64v8 + - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm64v8 + ids: + - netbird-proxy + goarch: arm64 + use: buildx + dockerfile: proxy/Dockerfile + build_flag_templates: + - "--platform=linux/arm64" + - "--label=org.opencontainers.image.created={{.Date}}" + - "--label=org.opencontainers.image.title={{.ProjectName}}" + - "--label=org.opencontainers.image.version={{.Version}}" + - "--label=org.opencontainers.image.revision={{.FullCommit}}" + - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" + - "--label=maintainer=dev@netbird.io" + - image_templates: + - netbirdio/reverse-proxy:{{ .Version }}-arm + - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm + ids: + - netbird-proxy + goarch: arm + goarm: 6 + use: buildx + dockerfile: proxy/Dockerfile + build_flag_templates: + - "--platform=linux/arm" + - "--label=org.opencontainers.image.created={{.Date}}" + - "--label=org.opencontainers.image.title={{.ProjectName}}" + - "--label=org.opencontainers.image.version={{.Version}}" + - "--label=org.opencontainers.image.revision={{.FullCommit}}" + - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" + - "--label=maintainer=dev@netbird.io" docker_manifests: - name_template: netbirdio/netbird:{{ .Version }} image_templates: @@ -598,6 +731,18 @@ docker_manifests: - netbirdio/upload:{{ .Version }}-arm - netbirdio/upload:{{ .Version }}-amd64 + - name_template: netbirdio/netbird-server:{{ .Version }} + image_templates: + - netbirdio/netbird-server:{{ .Version }}-arm64v8 + - netbirdio/netbird-server:{{ .Version }}-arm + - netbirdio/netbird-server:{{ .Version }}-amd64 + + - name_template: netbirdio/netbird-server:latest + image_templates: + - netbirdio/netbird-server:{{ .Version }}-arm64v8 + - netbirdio/netbird-server:{{ .Version }}-arm + - netbirdio/netbird-server:{{ .Version }}-amd64 + - name_template: ghcr.io/netbirdio/netbird:{{ .Version }} image_templates: - ghcr.io/netbirdio/netbird:{{ .Version }}-arm64v8 @@ -675,6 +820,43 @@ docker_manifests: - ghcr.io/netbirdio/upload:{{ .Version }}-arm64v8 - ghcr.io/netbirdio/upload:{{ .Version }}-arm - ghcr.io/netbirdio/upload:{{ .Version }}-amd64 + + - name_template: ghcr.io/netbirdio/netbird-server:{{ .Version }} + image_templates: + - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm64v8 + - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm + - ghcr.io/netbirdio/netbird-server:{{ .Version }}-amd64 + + - name_template: ghcr.io/netbirdio/netbird-server:latest + image_templates: + - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm64v8 + - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm + - ghcr.io/netbirdio/netbird-server:{{ .Version }}-amd64 + + - name_template: netbirdio/reverse-proxy:{{ .Version }} + image_templates: + - netbirdio/reverse-proxy:{{ .Version }}-arm64v8 + - netbirdio/reverse-proxy:{{ .Version }}-arm + - netbirdio/reverse-proxy:{{ .Version }}-amd64 + + - name_template: netbirdio/reverse-proxy:latest + image_templates: + - netbirdio/reverse-proxy:{{ .Version }}-arm64v8 + - netbirdio/reverse-proxy:{{ .Version }}-arm + - netbirdio/reverse-proxy:{{ .Version }}-amd64 + + - name_template: ghcr.io/netbirdio/reverse-proxy:{{ .Version }} + image_templates: + - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm64v8 + - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm + - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-amd64 + + - name_template: ghcr.io/netbirdio/reverse-proxy:latest + image_templates: + - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm64v8 + - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm + - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-amd64 + brews: - ids: - default @@ -695,7 +877,7 @@ brews: uploads: - name: debian ids: - - netbird-deb + - netbird_deb mode: archive target: https://pkgs.wiretrustee.com/debian/pool/{{ .ArtifactName }};deb.distribution=stable;deb.component=main;deb.architecture={{ if .Arm }}armhf{{ else }}{{ .Arch }}{{ end }};deb.package= username: dev@wiretrustee.com @@ -703,7 +885,7 @@ uploads: - name: yum ids: - - netbird-rpm + - netbird_rpm mode: archive target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }} username: dev@wiretrustee.com diff --git a/.goreleaser_ui.yaml b/.goreleaser_ui.yaml index a243702ea..470f1deaa 100644 --- a/.goreleaser_ui.yaml +++ b/.goreleaser_ui.yaml @@ -61,7 +61,7 @@ nfpms: - maintainer: Netbird description: Netbird client UI. homepage: https://netbird.io/ - id: netbird-ui-deb + id: netbird_ui_deb package_name: netbird-ui builds: - netbird-ui @@ -80,7 +80,7 @@ nfpms: - maintainer: Netbird description: Netbird client UI. homepage: https://netbird.io/ - id: netbird-ui-rpm + id: netbird_ui_rpm package_name: netbird-ui builds: - netbird-ui @@ -95,11 +95,14 @@ nfpms: dst: /usr/share/pixmaps/netbird.png dependencies: - netbird + rpm: + signature: + key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}' uploads: - name: debian ids: - - netbird-ui-deb + - netbird_ui_deb mode: archive target: https://pkgs.wiretrustee.com/debian/pool/{{ .ArtifactName }};deb.distribution=stable;deb.component=main;deb.architecture={{ if .Arm }}armhf{{ else }}{{ .Arch }}{{ end }};deb.package= username: dev@wiretrustee.com @@ -107,7 +110,7 @@ uploads: - name: yum ids: - - netbird-ui-rpm + - netbird_ui_rpm mode: archive target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }} username: dev@wiretrustee.com diff --git a/LICENSE b/LICENSE index 594691464..d922f155a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -This BSD‑3‑Clause license applies to all parts of the repository except for the directories management/, signal/ and relay/. +This BSD‑3‑Clause license applies to all parts of the repository except for the directories management/, signal/, relay/ and combined/. Those directories are licensed under the GNU Affero General Public License version 3.0 (AGPLv3). See the respective LICENSE files inside each directory. BSD 3-Clause License diff --git a/README.md b/README.md index 8f4c04641..dc84af2fd 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,8 @@ https://github.com/user-attachments/assets/10cec749-bb56-4ab3-97af-4e38850108d2 -### NetBird on Lawrence Systems (Video) -[![Watch the video](https://img.youtube.com/vi/Kwrff6h0rEw/0.jpg)](https://www.youtube.com/watch?v=Kwrff6h0rEw) +### Self-Host NetBird (Video) +[![Watch the video](https://img.youtube.com/vi/bZAgpT6nzaQ/0.jpg)](https://youtu.be/bZAgpT6nzaQ) ### Key features @@ -126,6 +126,7 @@ See a complete [architecture overview](https://docs.netbird.io/about-netbird/how ### Community projects - [NetBird installer script](https://github.com/physk/netbird-installer) - [NetBird ansible collection by Dominion Solutions](https://galaxy.ansible.com/ui/repo/published/dominion_solutions/netbird/) +- [netbird-tui](https://github.com/n0pashkov/netbird-tui) — terminal UI for managing NetBird peers, routes, and settings **Note**: The `main` branch may be in an *unstable or even broken state* during development. For stable versions, see [releases](https://github.com/netbirdio/netbird/releases). diff --git a/client/Dockerfile b/client/Dockerfile index 2ff0cca19..13e44096f 100644 --- a/client/Dockerfile +++ b/client/Dockerfile @@ -4,7 +4,7 @@ # sudo podman build -t localhost/netbird:latest -f client/Dockerfile --ignorefile .dockerignore-client . # sudo podman run --rm -it --cap-add={BPF,NET_ADMIN,NET_RAW} localhost/netbird:latest -FROM alpine:3.23.2 +FROM alpine:3.23.3 # iproute2: busybox doesn't display ip rules properly RUN apk add --no-cache \ bash \ diff --git a/client/android/client.go b/client/android/client.go index ccf32a90c..3fc571559 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -124,7 +124,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid // todo do not throw error in case of cancelled context ctx = internal.CtxInitState(ctx) - c.connectClient = internal.NewConnectClient(ctx, cfg, c.recorder, false) + c.connectClient = internal.NewConnectClient(ctx, cfg, c.recorder) return c.connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile) } @@ -157,7 +157,7 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR // todo do not throw error in case of cancelled context ctx = internal.CtxInitState(ctx) - c.connectClient = internal.NewConnectClient(ctx, cfg, c.recorder, false) + c.connectClient = internal.NewConnectClient(ctx, cfg, c.recorder) return c.connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile) } diff --git a/client/android/env_list.go b/client/android/env_list.go index 04122300a..a0a4d7040 100644 --- a/client/android/env_list.go +++ b/client/android/env_list.go @@ -1,10 +1,19 @@ package android -import "github.com/netbirdio/netbird/client/internal/peer" +import ( + "github.com/netbirdio/netbird/client/internal/lazyconn" + "github.com/netbirdio/netbird/client/internal/peer" +) var ( - // EnvKeyNBForceRelay Exported for Android java client + // EnvKeyNBForceRelay Exported for Android java client to force relay connections EnvKeyNBForceRelay = peer.EnvKeyNBForceRelay + + // EnvKeyNBLazyConn Exported for Android java client to configure lazy connection + EnvKeyNBLazyConn = lazyconn.EnvEnableLazyConn + + // EnvKeyNBInactivityThreshold Exported for Android java client to configure connection inactivity threshold + EnvKeyNBInactivityThreshold = lazyconn.EnvInactivityThreshold ) // EnvList wraps a Go map for export to Java diff --git a/client/cmd/expose.go b/client/cmd/expose.go new file mode 100644 index 000000000..1334617d8 --- /dev/null +++ b/client/cmd/expose.go @@ -0,0 +1,280 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/signal" + "regexp" + "strconv" + "strings" + "syscall" + + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/util" +) + +var pinRegexp = regexp.MustCompile(`^\d{6}$`) + +var ( + exposePin string + exposePassword string + exposeUserGroups []string + exposeDomain string + exposeNamePrefix string + exposeProtocol string + exposeExternalPort uint16 +) + +var exposeCmd = &cobra.Command{ + Use: "expose ", + Short: "Expose a local port via the NetBird reverse proxy", + Args: cobra.ExactArgs(1), + Example: ` netbird expose --with-password safe-pass 8080 + netbird expose --protocol tcp 5432 + netbird expose --protocol tcp --with-external-port 5433 5432 + netbird expose --protocol tls --with-custom-domain tls.example.com 4443`, + RunE: exposeFn, +} + +func init() { + exposeCmd.Flags().StringVar(&exposePin, "with-pin", "", "Protect the exposed service with a 6-digit PIN (e.g. --with-pin 123456)") + exposeCmd.Flags().StringVar(&exposePassword, "with-password", "", "Protect the exposed service with a password (e.g. --with-password my-secret)") + exposeCmd.Flags().StringSliceVar(&exposeUserGroups, "with-user-groups", nil, "Restrict access to specific user groups with SSO (e.g. --with-user-groups devops,Backend)") + exposeCmd.Flags().StringVar(&exposeDomain, "with-custom-domain", "", "Custom domain for the exposed service, must be configured to your account (e.g. --with-custom-domain myapp.example.com)") + exposeCmd.Flags().StringVar(&exposeNamePrefix, "with-name-prefix", "", "Prefix for the generated service name (e.g. --with-name-prefix my-app)") + exposeCmd.Flags().StringVar(&exposeProtocol, "protocol", "http", "Protocol to use: http, https, tcp, udp, or tls (e.g. --protocol tcp)") + exposeCmd.Flags().Uint16Var(&exposeExternalPort, "with-external-port", 0, "Public-facing external port on the proxy cluster (defaults to the target port for L4)") +} + +// isClusterProtocol returns true for L4/TLS protocols that reject HTTP-style auth flags. +func isClusterProtocol(protocol string) bool { + switch strings.ToLower(protocol) { + case "tcp", "udp", "tls": + return true + default: + return false + } +} + +// isPortBasedProtocol returns true for pure port-based protocols (TCP/UDP) +// where domain display doesn't apply. TLS uses SNI so it has a domain. +func isPortBasedProtocol(protocol string) bool { + switch strings.ToLower(protocol) { + case "tcp", "udp": + return true + default: + return false + } +} + +// extractPort returns the port portion of a URL like "tcp://host:12345", or +// falls back to the given default formatted as a string. +func extractPort(serviceURL string, fallback uint16) string { + u := serviceURL + if idx := strings.Index(u, "://"); idx != -1 { + u = u[idx+3:] + } + if i := strings.LastIndex(u, ":"); i != -1 { + if p := u[i+1:]; p != "" { + return p + } + } + return strconv.FormatUint(uint64(fallback), 10) +} + +// resolveExternalPort returns the effective external port, defaulting to the target port. +func resolveExternalPort(targetPort uint64) uint16 { + if exposeExternalPort != 0 { + return exposeExternalPort + } + return uint16(targetPort) +} + +func validateExposeFlags(cmd *cobra.Command, portStr string) (uint64, error) { + port, err := strconv.ParseUint(portStr, 10, 32) + if err != nil { + return 0, fmt.Errorf("invalid port number: %s", portStr) + } + if port == 0 || port > 65535 { + return 0, fmt.Errorf("invalid port number: must be between 1 and 65535") + } + + if !isProtocolValid(exposeProtocol) { + return 0, fmt.Errorf("unsupported protocol %q: must be http, https, tcp, udp, or tls", exposeProtocol) + } + + if isClusterProtocol(exposeProtocol) { + if exposePin != "" || exposePassword != "" || len(exposeUserGroups) > 0 { + return 0, fmt.Errorf("auth flags (--with-pin, --with-password, --with-user-groups) are not supported for %s protocol", exposeProtocol) + } + } else if cmd.Flags().Changed("with-external-port") { + return 0, fmt.Errorf("--with-external-port is not supported for %s protocol", exposeProtocol) + } + + if exposePin != "" && !pinRegexp.MatchString(exposePin) { + return 0, fmt.Errorf("invalid pin: must be exactly 6 digits") + } + + if cmd.Flags().Changed("with-password") && exposePassword == "" { + return 0, fmt.Errorf("password cannot be empty") + } + + if cmd.Flags().Changed("with-user-groups") && len(exposeUserGroups) == 0 { + return 0, fmt.Errorf("user groups cannot be empty") + } + + return port, nil +} + +func isProtocolValid(exposeProtocol string) bool { + switch strings.ToLower(exposeProtocol) { + case "http", "https", "tcp", "udp", "tls": + return true + default: + return false + } +} + +func exposeFn(cmd *cobra.Command, args []string) error { + SetFlagsFromEnvVars(rootCmd) + + if err := util.InitLog(logLevel, util.LogConsole); err != nil { + log.Errorf("failed initializing log %v", err) + return err + } + + cmd.Root().SilenceUsage = false + + port, err := validateExposeFlags(cmd, args[0]) + if err != nil { + return err + } + + cmd.Root().SilenceUsage = true + + ctx, cancel := context.WithCancel(cmd.Context()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + cancel() + }() + + conn, err := DialClientGRPCServer(ctx, daemonAddr) + if err != nil { + return fmt.Errorf("connect to daemon: %w", err) + } + defer func() { + if err := conn.Close(); err != nil { + log.Debugf("failed to close daemon connection: %v", err) + } + }() + + client := proto.NewDaemonServiceClient(conn) + + protocol, err := toExposeProtocol(exposeProtocol) + if err != nil { + return err + } + + req := &proto.ExposeServiceRequest{ + Port: uint32(port), + Protocol: protocol, + Pin: exposePin, + Password: exposePassword, + UserGroups: exposeUserGroups, + Domain: exposeDomain, + NamePrefix: exposeNamePrefix, + } + if isClusterProtocol(exposeProtocol) { + req.ListenPort = uint32(resolveExternalPort(port)) + } + + stream, err := client.ExposeService(ctx, req) + if err != nil { + return fmt.Errorf("expose service: %w", err) + } + + if err := handleExposeReady(cmd, stream, port); err != nil { + return err + } + + return waitForExposeEvents(cmd, ctx, stream) +} + +func toExposeProtocol(exposeProtocol string) (proto.ExposeProtocol, error) { + switch strings.ToLower(exposeProtocol) { + case "http": + return proto.ExposeProtocol_EXPOSE_HTTP, nil + case "https": + return proto.ExposeProtocol_EXPOSE_HTTPS, nil + case "tcp": + return proto.ExposeProtocol_EXPOSE_TCP, nil + case "udp": + return proto.ExposeProtocol_EXPOSE_UDP, nil + case "tls": + return proto.ExposeProtocol_EXPOSE_TLS, nil + default: + return 0, fmt.Errorf("unsupported protocol %q: must be http, https, tcp, udp, or tls", exposeProtocol) + } +} + +func handleExposeReady(cmd *cobra.Command, stream proto.DaemonService_ExposeServiceClient, port uint64) error { + event, err := stream.Recv() + if err != nil { + return fmt.Errorf("receive expose event: %w", err) + } + + ready, ok := event.Event.(*proto.ExposeServiceEvent_Ready) + if !ok { + return fmt.Errorf("unexpected expose event: %T", event.Event) + } + printExposeReady(cmd, ready.Ready, port) + return nil +} + +func printExposeReady(cmd *cobra.Command, r *proto.ExposeServiceReady, port uint64) { + cmd.Println("Service exposed successfully!") + cmd.Printf(" Name: %s\n", r.ServiceName) + if r.ServiceUrl != "" { + cmd.Printf(" URL: %s\n", r.ServiceUrl) + } + if r.Domain != "" && !isPortBasedProtocol(exposeProtocol) { + cmd.Printf(" Domain: %s\n", r.Domain) + } + cmd.Printf(" Protocol: %s\n", exposeProtocol) + cmd.Printf(" Internal: %d\n", port) + if isClusterProtocol(exposeProtocol) { + cmd.Printf(" External: %s\n", extractPort(r.ServiceUrl, resolveExternalPort(port))) + } + if r.PortAutoAssigned && exposeExternalPort != 0 { + cmd.Printf("\n Note: requested port %d was reassigned\n", exposeExternalPort) + } + cmd.Println() + cmd.Println("Press Ctrl+C to stop exposing.") +} + +func waitForExposeEvents(cmd *cobra.Command, ctx context.Context, stream proto.DaemonService_ExposeServiceClient) error { + for { + _, err := stream.Recv() + if err != nil { + if ctx.Err() != nil { + cmd.Println("\nService stopped.") + //nolint:nilerr + return nil + } + if errors.Is(err, io.EOF) { + return fmt.Errorf("connection to daemon closed unexpectedly") + } + return fmt.Errorf("stream error: %w", err) + } + } +} diff --git a/client/cmd/login.go b/client/cmd/login.go index 64b45e557..4521a67c9 100644 --- a/client/cmd/login.go +++ b/client/cmd/login.go @@ -282,13 +282,9 @@ func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profileman } defer authClient.Close() - needsLogin := false - - err, isAuthError := authClient.Login(ctx, "", "") - if isAuthError { - needsLogin = true - } else if err != nil { - return fmt.Errorf("login check failed: %v", err) + needsLogin, err := authClient.IsLoginRequired(ctx) + if err != nil { + return fmt.Errorf("check login required: %v", err) } jwtToken := "" diff --git a/client/cmd/root.go b/client/cmd/root.go index f4f4f6052..aa5b98dfd 100644 --- a/client/cmd/root.go +++ b/client/cmd/root.go @@ -22,6 +22,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" + daddr "github.com/netbirdio/netbird/client/internal/daemonaddr" "github.com/netbirdio/netbird/client/internal/profilemanager" ) @@ -80,6 +81,15 @@ var ( Short: "", Long: "", SilenceUsage: true, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + SetFlagsFromEnvVars(cmd.Root()) + + // Don't resolve for service commands — they create the socket, not connect to it. + if !isServiceCmd(cmd) { + daemonAddr = daddr.ResolveUnixDaemonAddr(daemonAddr) + } + return nil + }, } ) @@ -144,6 +154,7 @@ func init() { rootCmd.AddCommand(forwardingRulesCmd) rootCmd.AddCommand(debugCmd) rootCmd.AddCommand(profileCmd) + rootCmd.AddCommand(exposeCmd) networksCMD.AddCommand(routesListCmd) networksCMD.AddCommand(routesSelectCmd, routesDeselectCmd) @@ -385,7 +396,6 @@ func migrateToNetbird(oldPath, newPath string) bool { } func getClient(cmd *cobra.Command) (*grpc.ClientConn, error) { - SetFlagsFromEnvVars(rootCmd) cmd.SetOut(cmd.OutOrStdout()) conn, err := DialClientGRPCServer(cmd.Context(), daemonAddr) @@ -398,3 +408,13 @@ func getClient(cmd *cobra.Command) (*grpc.ClientConn, error) { return conn, nil } + +// isServiceCmd returns true if cmd is the "service" command or a child of it. +func isServiceCmd(cmd *cobra.Command) bool { + for c := cmd; c != nil; c = c.Parent() { + if c.Name() == "service" { + return true + } + } + return false +} diff --git a/client/cmd/signer/artifactkey.go b/client/cmd/signer/artifactkey.go index 5e656650b..ee12326db 100644 --- a/client/cmd/signer/artifactkey.go +++ b/client/cmd/signer/artifactkey.go @@ -7,7 +7,7 @@ import ( "github.com/spf13/cobra" - "github.com/netbirdio/netbird/client/internal/updatemanager/reposign" + "github.com/netbirdio/netbird/client/internal/updater/reposign" ) var ( diff --git a/client/cmd/signer/artifactsign.go b/client/cmd/signer/artifactsign.go index 881be9367..7c02323dc 100644 --- a/client/cmd/signer/artifactsign.go +++ b/client/cmd/signer/artifactsign.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" - "github.com/netbirdio/netbird/client/internal/updatemanager/reposign" + "github.com/netbirdio/netbird/client/internal/updater/reposign" ) const ( diff --git a/client/cmd/signer/revocation.go b/client/cmd/signer/revocation.go index 1d84b65c3..5ff636dcb 100644 --- a/client/cmd/signer/revocation.go +++ b/client/cmd/signer/revocation.go @@ -7,7 +7,7 @@ import ( "github.com/spf13/cobra" - "github.com/netbirdio/netbird/client/internal/updatemanager/reposign" + "github.com/netbirdio/netbird/client/internal/updater/reposign" ) const ( diff --git a/client/cmd/signer/rootkey.go b/client/cmd/signer/rootkey.go index 78ac36b41..eae0da84d 100644 --- a/client/cmd/signer/rootkey.go +++ b/client/cmd/signer/rootkey.go @@ -7,7 +7,7 @@ import ( "github.com/spf13/cobra" - "github.com/netbirdio/netbird/client/internal/updatemanager/reposign" + "github.com/netbirdio/netbird/client/internal/updater/reposign" ) var ( diff --git a/client/cmd/up.go b/client/cmd/up.go index 9559287d5..f5766522a 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -197,7 +197,7 @@ func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *pr r := peer.NewRecorder(config.ManagementURL.String()) r.GetFullStatus() - connectClient := internal.NewConnectClient(ctx, config, r, false) + connectClient := internal.NewConnectClient(ctx, config, r) SetupDebugHandler(ctx, config, r, connectClient, "") return connectClient.Run(nil, util.FindFirstLogPath(logFiles)) diff --git a/client/cmd/update_supported.go b/client/cmd/update_supported.go index 977875093..0b197f4c5 100644 --- a/client/cmd/update_supported.go +++ b/client/cmd/update_supported.go @@ -11,7 +11,7 @@ import ( log "github.com/sirupsen/logrus" "github.com/spf13/cobra" - "github.com/netbirdio/netbird/client/internal/updatemanager/installer" + "github.com/netbirdio/netbird/client/internal/updater/installer" "github.com/netbirdio/netbird/util" ) diff --git a/client/embed/embed.go b/client/embed/embed.go index e266aae28..70013989a 100644 --- a/client/embed/embed.go +++ b/client/embed/embed.go @@ -14,6 +14,7 @@ import ( "github.com/sirupsen/logrus" wgnetstack "golang.zx2c4.com/wireguard/tun/netstack" + "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/iface/netstack" "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/auth" @@ -31,6 +32,14 @@ var ( ErrConfigNotInitialized = errors.New("config not initialized") ) +// PeerConnStatus is a peer's connection status. +type PeerConnStatus = peer.ConnStatus + +const ( + // PeerStatusConnected indicates the peer is in connected state. + PeerStatusConnected = peer.StatusConnected +) + // Client manages a netbird embedded client instance. type Client struct { deviceName string @@ -69,6 +78,16 @@ type Options struct { StatePath string // DisableClientRoutes disables the client routes DisableClientRoutes bool + // BlockInbound blocks all inbound connections from peers + BlockInbound bool + // WireguardPort is the port for the WireGuard interface. Use 0 for a random port. + WireguardPort *int + // MTU is the MTU for the WireGuard interface. + // Valid values are in the range 576..8192 bytes. + // If non-nil, this value overrides any value stored in the config file. + // If nil, the existing config MTU (if non-zero) is preserved; otherwise it defaults to 1280. + // Set to a higher value (e.g. 1400) if carrying QUIC or other protocols that require larger datagrams. + MTU *uint16 } // validateCredentials checks that exactly one credential type is provided @@ -100,6 +119,12 @@ func New(opts Options) (*Client, error) { return nil, err } + if opts.MTU != nil { + if err := iface.ValidateMTU(*opts.MTU); err != nil { + return nil, fmt.Errorf("invalid MTU: %w", err) + } + } + if opts.LogOutput != nil { logrus.SetOutput(opts.LogOutput) } @@ -137,6 +162,9 @@ func New(opts Options) (*Client, error) { PreSharedKey: &opts.PreSharedKey, DisableServerRoutes: &t, DisableClientRoutes: &opts.DisableClientRoutes, + BlockInbound: &opts.BlockInbound, + WireguardPort: opts.WireguardPort, + MTU: opts.MTU, } if opts.ConfigPath != "" { config, err = profilemanager.UpdateOrCreateConfig(input) @@ -156,6 +184,7 @@ func New(opts Options) (*Client, error) { setupKey: opts.SetupKey, jwtToken: opts.JWTToken, config: config, + recorder: peer.NewRecorder(config.ManagementURL.String()), }, nil } @@ -177,6 +206,7 @@ func (c *Client) Start(startCtx context.Context) error { // nolint:staticcheck ctx = context.WithValue(ctx, system.DeviceNameCtxKey, c.deviceName) + authClient, err := auth.NewAuth(ctx, c.config.PrivateKey, c.config.ManagementURL, c.config) if err != nil { return fmt.Errorf("create auth client: %w", err) @@ -186,10 +216,7 @@ func (c *Client) Start(startCtx context.Context) error { if err, _ := authClient.Login(ctx, c.setupKey, c.jwtToken); err != nil { return fmt.Errorf("login: %w", err) } - - recorder := peer.NewRecorder(c.config.ManagementURL.String()) - c.recorder = recorder - client := internal.NewConnectClient(ctx, c.config, recorder, false) + client := internal.NewConnectClient(ctx, c.config, c.recorder) client.SetSyncResponsePersistence(true) // either startup error (permanent backoff err) or nil err (successful engine up) @@ -342,14 +369,9 @@ func (c *Client) NewHTTPClient() *http.Client { // Status returns the current status of the client. func (c *Client) Status() (peer.FullStatus, error) { c.mu.Lock() - recorder := c.recorder connect := c.connect c.mu.Unlock() - if recorder == nil { - return peer.FullStatus{}, errors.New("client not started") - } - if connect != nil { engine := connect.Engine() if engine != nil { @@ -357,7 +379,7 @@ func (c *Client) Status() (peer.FullStatus, error) { } } - return recorder.GetFullStatus(), nil + return c.recorder.GetFullStatus(), nil } // GetLatestSyncResponse returns the latest sync response from the management server. diff --git a/client/firewall/nftables/router_linux.go b/client/firewall/nftables/router_linux.go index b6e0cf5b2..fde654c20 100644 --- a/client/firewall/nftables/router_linux.go +++ b/client/firewall/nftables/router_linux.go @@ -483,7 +483,12 @@ func (r *router) DeleteRouteRule(rule firewall.Rule) error { } if nftRule.Handle == 0 { - return fmt.Errorf("route rule %s has no handle", ruleKey) + 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 { @@ -660,13 +665,32 @@ func (r *router) AddNatRule(pair firewall.RouterPair) error { } if err := r.conn.Flush(); err != nil { - // TODO: rollback ipset counter - return fmt.Errorf("insert rules for %s: %v", pair.Destination, err) + 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) @@ -928,18 +952,30 @@ func (r *router) addLegacyRouteRule(pair firewall.RouterPair) error { func (r *router) removeLegacyRouteRule(pair firewall.RouterPair) error { ruleKey := firewall.GenKey(firewall.ForwardingFormat, pair) - if rule, exists := r.rules[ruleKey]; exists { - if err := r.conn.DelRule(rule); err != nil { - return fmt.Errorf("remove legacy forwarding rule %s -> %s: %v", pair.Source, pair.Destination, err) - } - - log.Debugf("removed legacy forwarding rule %s -> %s", pair.Source, pair.Destination) - - delete(r.rules, ruleKey) + 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 { - return fmt.Errorf("decrement set counter: %w", err) + 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 @@ -1329,65 +1365,89 @@ func (r *router) RemoveNatRule(pair firewall.RouterPair) error { return fmt.Errorf(refreshRulesMapError, err) } + var merr *multierror.Error + if pair.Masquerade { if err := r.removeNatRule(pair); err != nil { - return fmt.Errorf("remove prerouting rule: %w", err) + merr = multierror.Append(merr, fmt.Errorf("remove prerouting rule: %w", err)) } if err := r.removeNatRule(firewall.GetInversePair(pair)); err != nil { - return fmt.Errorf("remove inverse prerouting rule: %w", err) + merr = multierror.Append(merr, fmt.Errorf("remove inverse prerouting rule: %w", err)) } } if err := r.removeLegacyRouteRule(pair); err != nil { - return fmt.Errorf("remove legacy routing rule: %w", err) + 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 { - // TODO: rollback set counter - return fmt.Errorf("remove nat rules rule %s: %v", pair.Destination, err) + merr = multierror.Append(merr, fmt.Errorf("flush remove nat rules %s: %w", pair.Destination, err)) } - return nil + return nberrors.FormatErrorOrNil(merr) } func (r *router) removeNatRule(pair firewall.RouterPair) error { ruleKey := firewall.GenKey(firewall.PreroutingFormat, pair) - if rule, exists := r.rules[ruleKey]; exists { - if err := r.conn.DelRule(rule); err != nil { - return fmt.Errorf("remove prerouting rule %s -> %s: %v", 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) - } - } else { + 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 refreshes the rule map with the latest rules. this is useful to avoid -// duplicates and to get missing attributes that we don't have when adding new rules +// 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 { - return fmt.Errorf("list rules: %w", err) + 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 { - r.rules[string(rule.UserData)] = rule + newRules[string(rule.UserData)] = rule } } } - return nil + r.rules = newRules + return nberrors.FormatErrorOrNil(merr) } func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) { @@ -1629,20 +1689,34 @@ func (r *router) DeleteDNATRule(rule firewall.Rule) error { } var merr *multierror.Error + var needsFlush bool + if dnatRule, exists := r.rules[ruleKey+dnatSuffix]; exists { - if err := r.conn.DelRule(dnatRule); err != nil { + 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 err := r.conn.DelRule(masqRule); err != nil { + 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 err := r.conn.Flush(); err != nil { - merr = multierror.Append(merr, fmt.Errorf(flushError, err)) + if needsFlush { + if err := r.conn.Flush(); err != nil { + merr = multierror.Append(merr, fmt.Errorf(flushError, err)) + } } if merr == nil { @@ -1757,16 +1831,25 @@ func (r *router) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Proto ruleID := fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, sourcePort, targetPort) - if rule, exists := r.rules[ruleID]; exists { - 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) + 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 } diff --git a/client/firewall/nftables/router_linux_test.go b/client/firewall/nftables/router_linux_test.go index 3531b014b..f0e34d211 100644 --- a/client/firewall/nftables/router_linux_test.go +++ b/client/firewall/nftables/router_linux_test.go @@ -18,6 +18,7 @@ import ( firewall "github.com/netbirdio/netbird/client/firewall/manager" "github.com/netbirdio/netbird/client/firewall/test" "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/internal/acl/id" ) const ( @@ -719,3 +720,137 @@ func deleteWorkTable() { } } } + +func TestRouter_RefreshRulesMap_RemovesStaleEntries(t *testing.T) { + if check() != NFTABLES { + t.Skip("nftables not supported on this system") + } + + workTable, err := createWorkTable() + require.NoError(t, err) + defer deleteWorkTable() + + r, err := newRouter(workTable, ifaceMock, iface.DefaultMTU) + require.NoError(t, err) + require.NoError(t, r.init(workTable)) + defer func() { require.NoError(t, r.Reset()) }() + + // Add a real rule to the kernel + ruleKey, err := r.AddRouteFiltering( + 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) + t.Cleanup(func() { + require.NoError(t, r.DeleteRouteRule(ruleKey)) + }) + + // Inject a stale entry with Handle=0 (simulates store-before-flush failure) + staleKey := "stale-rule-that-does-not-exist" + r.rules[staleKey] = &nftables.Rule{ + Table: r.workTable, + Chain: r.chains[chainNameRoutingFw], + Handle: 0, + UserData: []byte(staleKey), + } + + require.Contains(t, r.rules, staleKey, "stale entry should be in map before refresh") + + err = r.refreshRulesMap() + require.NoError(t, err) + + assert.NotContains(t, r.rules, staleKey, "stale entry should be removed after refresh") + + realRule, ok := r.rules[ruleKey.ID()] + assert.True(t, ok, "real rule should still exist after refresh") + assert.NotZero(t, realRule.Handle, "real rule should have a valid handle") +} + +func TestRouter_DeleteRouteRule_StaleHandle(t *testing.T) { + if check() != NFTABLES { + t.Skip("nftables not supported on this system") + } + + workTable, err := createWorkTable() + require.NoError(t, err) + defer deleteWorkTable() + + r, err := newRouter(workTable, ifaceMock, iface.DefaultMTU) + require.NoError(t, err) + 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), + } + + // DeleteRouteRule should not return an error for stale handles + err = r.DeleteRouteRule(id.RuleID(staleKey)) + assert.NoError(t, err, "deleting a stale rule should not error") + assert.NotContains(t, r.rules, staleKey, "stale entry should be cleaned up") +} + +func TestRouter_AddNatRule_WithStaleEntry(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)) + }) + + pair := firewall.RouterPair{ + ID: "staletest", + Source: firewall.Network{Prefix: netip.MustParsePrefix("100.100.100.1/32")}, + Destination: firewall.Network{Prefix: netip.MustParsePrefix("100.100.200.0/24")}, + Masquerade: true, + } + + rtr := manager.router + + // First add succeeds + err = rtr.AddNatRule(pair) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, rtr.RemoveNatRule(pair)) + }) + + // Corrupt the handle to simulate stale state + natRuleKey := firewall.GenKey(firewall.PreroutingFormat, pair) + if rule, exists := rtr.rules[natRuleKey]; exists { + rule.Handle = 0 + } + inverseKey := firewall.GenKey(firewall.PreroutingFormat, firewall.GetInversePair(pair)) + if rule, exists := rtr.rules[inverseKey]; exists { + rule.Handle = 0 + } + + // Adding the same rule again should succeed despite stale handles + err = rtr.AddNatRule(pair) + assert.NoError(t, err, "AddNatRule should succeed even with stale entries") + + // Verify rules exist in kernel + rules, err := rtr.conn.GetRules(rtr.workTable, rtr.chains[chainNameManglePrerouting]) + require.NoError(t, err) + + found := 0 + for _, rule := range rules { + if len(rule.UserData) > 0 && string(rule.UserData) == natRuleKey { + found++ + } + } + assert.Equal(t, 1, found, "NAT rule should exist in kernel") +} diff --git a/client/firewall/uspfilter/allow_netbird.go b/client/firewall/uspfilter/allow_netbird.go index 22e6fca1f..6a6533344 100644 --- a/client/firewall/uspfilter/allow_netbird.go +++ b/client/firewall/uspfilter/allow_netbird.go @@ -3,12 +3,6 @@ package uspfilter import ( - "context" - "net/netip" - "time" - - log "github.com/sirupsen/logrus" - "github.com/netbirdio/netbird/client/internal/statemanager" ) @@ -17,33 +11,7 @@ func (m *Manager) Close(stateManager *statemanager.Manager) error { m.mutex.Lock() defer m.mutex.Unlock() - m.outgoingRules = make(map[netip.Addr]RuleSet) - m.incomingDenyRules = make(map[netip.Addr]RuleSet) - m.incomingRules = make(map[netip.Addr]RuleSet) - - if m.udpTracker != nil { - m.udpTracker.Close() - } - - if m.icmpTracker != nil { - m.icmpTracker.Close() - } - - if m.tcpTracker != nil { - m.tcpTracker.Close() - } - - if fwder := m.forwarder.Load(); fwder != nil { - fwder.Stop() - } - - if m.logger != nil { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - if err := m.logger.Stop(ctx); err != nil { - log.Errorf("failed to shutdown logger: %v", err) - } - } + m.resetState() if m.nativeFirewall != nil { return m.nativeFirewall.Close(stateManager) diff --git a/client/firewall/uspfilter/allow_netbird_windows.go b/client/firewall/uspfilter/allow_netbird_windows.go index 8a56b0862..6aef2ecfd 100644 --- a/client/firewall/uspfilter/allow_netbird_windows.go +++ b/client/firewall/uspfilter/allow_netbird_windows.go @@ -1,12 +1,9 @@ package uspfilter import ( - "context" "fmt" - "net/netip" "os/exec" "syscall" - "time" log "github.com/sirupsen/logrus" @@ -26,33 +23,7 @@ func (m *Manager) Close(*statemanager.Manager) error { m.mutex.Lock() defer m.mutex.Unlock() - m.outgoingRules = make(map[netip.Addr]RuleSet) - m.incomingDenyRules = make(map[netip.Addr]RuleSet) - m.incomingRules = make(map[netip.Addr]RuleSet) - - if m.udpTracker != nil { - m.udpTracker.Close() - } - - if m.icmpTracker != nil { - m.icmpTracker.Close() - } - - if m.tcpTracker != nil { - m.tcpTracker.Close() - } - - if fwder := m.forwarder.Load(); fwder != nil { - fwder.Stop() - } - - if m.logger != nil { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - if err := m.logger.Stop(ctx); err != nil { - log.Errorf("failed to shutdown logger: %v", err) - } - } + m.resetState() if !isWindowsFirewallReachable() { return nil diff --git a/client/firewall/uspfilter/conntrack/tcp.go b/client/firewall/uspfilter/conntrack/tcp.go index 8d64412e0..335a3abab 100644 --- a/client/firewall/uspfilter/conntrack/tcp.go +++ b/client/firewall/uspfilter/conntrack/tcp.go @@ -115,6 +115,17 @@ func (t *TCPConnTrack) IsTombstone() bool { return t.tombstone.Load() } +// IsSupersededBy returns true if this connection should be replaced by a new one +// carrying the given flags. Tombstoned connections are always superseded; TIME-WAIT +// connections are superseded by a pure SYN (a new connection attempt for the same +// four-tuple, as contemplated by RFC 1122 §4.2.2.13 and RFC 6191). +func (t *TCPConnTrack) IsSupersededBy(flags uint8) bool { + if t.tombstone.Load() { + return true + } + return flags&TCPSyn != 0 && flags&TCPAck == 0 && TCPState(t.state.Load()) == TCPStateTimeWait +} + // SetTombstone safely marks the connection for deletion func (t *TCPConnTrack) SetTombstone() { t.tombstone.Store(true) @@ -169,7 +180,7 @@ func (t *TCPTracker) updateIfExists(srcIP, dstIP netip.Addr, srcPort, dstPort ui conn, exists := t.connections[key] t.mutex.RUnlock() - if exists { + if exists && !conn.IsSupersededBy(flags) { t.updateState(key, conn, flags, direction, size) return key, uint16(conn.DNATOrigPort.Load()), true } @@ -241,7 +252,7 @@ func (t *TCPTracker) IsValidInbound(srcIP, dstIP netip.Addr, srcPort, dstPort ui conn, exists := t.connections[key] t.mutex.RUnlock() - if !exists || conn.IsTombstone() { + if !exists || conn.IsSupersededBy(flags) { return false } diff --git a/client/firewall/uspfilter/conntrack/tcp_test.go b/client/firewall/uspfilter/conntrack/tcp_test.go index bb440f70a..f46c5c1ab 100644 --- a/client/firewall/uspfilter/conntrack/tcp_test.go +++ b/client/firewall/uspfilter/conntrack/tcp_test.go @@ -485,6 +485,261 @@ func TestTCPAbnormalSequences(t *testing.T) { }) } +// TestTCPPortReuseTombstone verifies that a new connection on a port with a +// tombstoned (closed) conntrack entry is properly tracked. Without the fix, +// updateIfExists treats tombstoned entries as live, causing track() to skip +// creating a new connection. The subsequent SYN-ACK then fails IsValidInbound +// because the entry is tombstoned, and the response packet gets dropped by ACL. +func TestTCPPortReuseTombstone(t *testing.T) { + srcIP := netip.MustParseAddr("100.64.0.1") + dstIP := netip.MustParseAddr("100.64.0.2") + srcPort := uint16(12345) + dstPort := uint16(80) + + t.Run("Outbound port reuse after graceful close", func(t *testing.T) { + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + key := ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort} + + // Establish and gracefully close a connection (server-initiated close) + establishConnection(t, tracker, srcIP, dstIP, srcPort, dstPort) + + // Server sends FIN + valid := tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPAck, 0) + require.True(t, valid) + + // Client sends FIN-ACK + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0) + + // Server sends final ACK + valid = tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPAck, 0) + require.True(t, valid) + + // Connection should be tombstoned + conn := tracker.connections[key] + require.NotNil(t, conn, "old connection should still be in map") + require.True(t, conn.IsTombstone(), "old connection should be tombstoned") + + // Now reuse the same port for a new connection + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPSyn, 100) + + // The old tombstoned entry should be replaced with a new one + newConn := tracker.connections[key] + require.NotNil(t, newConn, "new connection should exist") + require.False(t, newConn.IsTombstone(), "new connection should not be tombstoned") + require.Equal(t, TCPStateSynSent, newConn.GetState()) + + // SYN-ACK for the new connection should be valid + valid = tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPSyn|TCPAck, 100) + require.True(t, valid, "SYN-ACK for new connection on reused port should be accepted") + require.Equal(t, TCPStateEstablished, newConn.GetState()) + + // Data transfer should work + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPAck, 100) + valid = tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPPush|TCPAck, 500) + require.True(t, valid, "data should be allowed on new connection") + }) + + t.Run("Outbound port reuse after RST", func(t *testing.T) { + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + key := ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort} + + // Establish and RST a connection + establishConnection(t, tracker, srcIP, dstIP, srcPort, dstPort) + valid := tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPRst|TCPAck, 0) + require.True(t, valid) + + conn := tracker.connections[key] + require.True(t, conn.IsTombstone(), "RST connection should be tombstoned") + + // Reuse the same port + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPSyn, 100) + + newConn := tracker.connections[key] + require.NotNil(t, newConn) + require.False(t, newConn.IsTombstone()) + require.Equal(t, TCPStateSynSent, newConn.GetState()) + + valid = tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPSyn|TCPAck, 100) + require.True(t, valid, "SYN-ACK should be accepted after RST tombstone") + }) + + t.Run("Inbound port reuse after close", func(t *testing.T) { + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + clientIP := srcIP + serverIP := dstIP + clientPort := srcPort + serverPort := dstPort + key := ConnKey{SrcIP: clientIP, DstIP: serverIP, SrcPort: clientPort, DstPort: serverPort} + + // Inbound connection: client SYN → server SYN-ACK → client ACK + tracker.TrackInbound(clientIP, serverIP, clientPort, serverPort, TCPSyn, nil, 100, 0) + tracker.TrackOutbound(serverIP, clientIP, serverPort, clientPort, TCPSyn|TCPAck, 100) + tracker.TrackInbound(clientIP, serverIP, clientPort, serverPort, TCPAck, nil, 100, 0) + + conn := tracker.connections[key] + require.Equal(t, TCPStateEstablished, conn.GetState()) + + // Server-initiated close to reach Closed/tombstoned: + // Server FIN (opposite dir) → CloseWait + tracker.TrackOutbound(serverIP, clientIP, serverPort, clientPort, TCPFin|TCPAck, 100) + require.Equal(t, TCPStateCloseWait, conn.GetState()) + // Client FIN-ACK (same dir as conn) → LastAck + tracker.TrackInbound(clientIP, serverIP, clientPort, serverPort, TCPFin|TCPAck, nil, 100, 0) + require.Equal(t, TCPStateLastAck, conn.GetState()) + // Server final ACK (opposite dir) → Closed → tombstoned + tracker.TrackOutbound(serverIP, clientIP, serverPort, clientPort, TCPAck, 100) + + require.True(t, conn.IsTombstone()) + + // New inbound connection on same ports + tracker.TrackInbound(clientIP, serverIP, clientPort, serverPort, TCPSyn, nil, 100, 0) + + newConn := tracker.connections[key] + require.NotNil(t, newConn) + require.False(t, newConn.IsTombstone()) + require.Equal(t, TCPStateSynReceived, newConn.GetState()) + + // Complete handshake: server SYN-ACK, then client ACK + tracker.TrackOutbound(serverIP, clientIP, serverPort, clientPort, TCPSyn|TCPAck, 100) + tracker.TrackInbound(clientIP, serverIP, clientPort, serverPort, TCPAck, nil, 100, 0) + require.Equal(t, TCPStateEstablished, newConn.GetState()) + }) + + t.Run("Late ACK on tombstoned connection is harmless", func(t *testing.T) { + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + key := ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort} + + // Establish and close via passive close (server-initiated FIN → Closed → tombstoned) + establishConnection(t, tracker, srcIP, dstIP, srcPort, dstPort) + tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPAck, 0) // CloseWait + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0) // LastAck + tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPAck, 0) // Closed + + conn := tracker.connections[key] + require.True(t, conn.IsTombstone()) + + // Late ACK should be rejected (tombstoned) + valid := tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPAck, 0) + require.False(t, valid, "late ACK on tombstoned connection should be rejected") + + // Late outbound ACK should not create a new connection (not a SYN) + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPAck, 0) + require.True(t, tracker.connections[key].IsTombstone(), "late outbound ACK should not replace tombstoned entry") + }) +} + +func TestTCPPortReuseTimeWait(t *testing.T) { + srcIP := netip.MustParseAddr("100.64.0.1") + dstIP := netip.MustParseAddr("100.64.0.2") + srcPort := uint16(12345) + dstPort := uint16(80) + + t.Run("Outbound port reuse during TIME-WAIT (active close)", func(t *testing.T) { + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + key := ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort} + + // Establish connection + establishConnection(t, tracker, srcIP, dstIP, srcPort, dstPort) + + // Active close: client (outbound initiator) sends FIN first + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0) + conn := tracker.connections[key] + require.Equal(t, TCPStateFinWait1, conn.GetState()) + + // Server ACKs the FIN + valid := tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPAck, 0) + require.True(t, valid) + require.Equal(t, TCPStateFinWait2, conn.GetState()) + + // Server sends its own FIN + valid = tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPAck, 0) + require.True(t, valid) + require.Equal(t, TCPStateTimeWait, conn.GetState()) + + // Client sends final ACK (TIME-WAIT stays, not tombstoned) + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPAck, 0) + require.False(t, conn.IsTombstone(), "TIME-WAIT should not be tombstoned") + + // New outbound SYN on the same port (port reuse during TIME-WAIT) + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPSyn, 100) + + // Per RFC 1122/6191, new SYN during TIME-WAIT should start a new connection + newConn := tracker.connections[key] + require.NotNil(t, newConn, "new connection should exist") + require.False(t, newConn.IsTombstone(), "new connection should not be tombstoned") + require.Equal(t, TCPStateSynSent, newConn.GetState(), "new connection should be in SYN-SENT") + + // SYN-ACK for new connection should be valid + valid = tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPSyn|TCPAck, 100) + require.True(t, valid, "SYN-ACK for new connection should be accepted") + require.Equal(t, TCPStateEstablished, newConn.GetState()) + }) + + t.Run("Inbound SYN during TIME-WAIT falls through to normal tracking", func(t *testing.T) { + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + key := ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort} + + // Establish outbound connection and close via active close → TIME-WAIT + establishConnection(t, tracker, srcIP, dstIP, srcPort, dstPort) + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0) + tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPAck, 0) + tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPAck, 0) + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPAck, 0) + + conn := tracker.connections[key] + require.Equal(t, TCPStateTimeWait, conn.GetState()) + + // Inbound SYN on same ports during TIME-WAIT: IsValidInbound returns false + // so the filter falls through to ACL check + TrackInbound (which creates + // a new connection via track() → updateIfExists skips TIME-WAIT for SYN) + valid := tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPSyn, 0) + require.False(t, valid, "inbound SYN during TIME-WAIT should fail conntrack validation") + + // Simulate what the filter does next: TrackInbound via the normal path + tracker.TrackInbound(dstIP, srcIP, dstPort, srcPort, TCPSyn, nil, 100, 0) + + // The new inbound connection uses the inverted key (dst→src becomes src→dst in track) + invertedKey := ConnKey{SrcIP: dstIP, DstIP: srcIP, SrcPort: dstPort, DstPort: srcPort} + newConn := tracker.connections[invertedKey] + require.NotNil(t, newConn, "new inbound connection should be tracked") + require.Equal(t, TCPStateSynReceived, newConn.GetState()) + require.False(t, newConn.IsTombstone()) + }) + + t.Run("Late retransmit during TIME-WAIT still allowed", func(t *testing.T) { + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + key := ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort} + + // Establish and active close → TIME-WAIT + establishConnection(t, tracker, srcIP, dstIP, srcPort, dstPort) + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0) + tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPAck, 0) + tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPAck, 0) + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPAck, 0) + + conn := tracker.connections[key] + require.Equal(t, TCPStateTimeWait, conn.GetState()) + + // Late ACK retransmits during TIME-WAIT should still be accepted + valid := tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPAck, 0) + require.True(t, valid, "retransmitted ACK during TIME-WAIT should be accepted") + }) +} + func TestTCPTimeoutHandling(t *testing.T) { // Create tracker with a very short timeout for testing shortTimeout := 100 * time.Millisecond diff --git a/client/firewall/uspfilter/filter.go b/client/firewall/uspfilter/filter.go index aacc4ca1c..df2e274eb 100644 --- a/client/firewall/uspfilter/filter.go +++ b/client/firewall/uspfilter/filter.go @@ -1,6 +1,7 @@ package uspfilter import ( + "context" "encoding/binary" "errors" "fmt" @@ -12,11 +13,13 @@ import ( "strings" "sync" "sync/atomic" + "time" "github.com/google/gopacket" "github.com/google/gopacket/layers" "github.com/google/uuid" log "github.com/sirupsen/logrus" + "golang.org/x/exp/maps" firewall "github.com/netbirdio/netbird/client/firewall/manager" "github.com/netbirdio/netbird/client/firewall/uspfilter/common" @@ -24,6 +27,7 @@ import ( "github.com/netbirdio/netbird/client/firewall/uspfilter/forwarder" nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log" "github.com/netbirdio/netbird/client/iface/netstack" + 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" ) @@ -89,6 +93,7 @@ type Manager struct { 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 @@ -229,6 +234,7 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe flowLogger: flowLogger, netstack: netstack.IsEnabled(), localForwarding: enableLocalForwarding, + routeRulesMap: make(map[nbid.RuleID]*RouteRule), dnatMappings: make(map[netip.Addr]netip.Addr), portDNATRules: []portDNATRule{}, netstackServices: make(map[serviceKey]struct{}), @@ -480,11 +486,15 @@ func (m *Manager) addRouteFiltering( return m.nativeFirewall.AddRouteFiltering(id, sources, destination, proto, sPort, dPort, action) } - ruleID := uuid.New().String() + ruleKey := nbid.GenerateRouteRuleKey(sources, destination, proto, sPort, dPort, action) + + if existingRule, ok := m.routeRulesMap[ruleKey]; ok { + return existingRule, nil + } rule := RouteRule{ // TODO: consolidate these IDs - id: ruleID, + id: string(ruleKey), mgmtId: id, sources: sources, dstSet: destination.Set, @@ -499,6 +509,7 @@ func (m *Manager) addRouteFiltering( m.routeRules = append(m.routeRules, &rule) m.routeRules.Sort() + m.routeRulesMap[ruleKey] = &rule return &rule, nil } @@ -515,15 +526,20 @@ func (m *Manager) deleteRouteRule(rule firewall.Rule) error { return m.nativeFirewall.DeleteRouteRule(rule) } - ruleID := rule.ID() + 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 == ruleID + return r.id == string(ruleKey) }) if idx < 0 { - return fmt.Errorf("route rule not found: %s", ruleID) + return fmt.Errorf("route rule not found in slice: %s", ruleKey) } m.routeRules = slices.Delete(m.routeRules, idx, idx+1) + delete(m.routeRulesMap, ruleKey) return nil } @@ -570,6 +586,40 @@ func (m *Manager) SetLegacyManagement(isLegacy bool) error { // Flush doesn't need to be implemented for this manager 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() { + maps.Clear(m.outgoingRules) + maps.Clear(m.incomingDenyRules) + maps.Clear(m.incomingRules) + maps.Clear(m.routeRulesMap) + m.routeRules = m.routeRules[:0] + + if m.udpTracker != nil { + m.udpTracker.Close() + } + + if m.icmpTracker != nil { + m.icmpTracker.Close() + } + + if m.tcpTracker != nil { + m.tcpTracker.Close() + } + + if fwder := m.forwarder.Load(); fwder != nil { + fwder.Stop() + } + + if m.logger != nil { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := m.logger.Stop(ctx); err != nil { + log.Errorf("failed to shutdown logger: %v", err) + } + } +} + // SetupEBPFProxyNoTrack creates notrack rules for eBPF proxy loopback traffic. func (m *Manager) SetupEBPFProxyNoTrack(proxyPort, wgPort uint16) error { if m.nativeFirewall == nil { diff --git a/client/firewall/uspfilter/filter_routeacl_test.go b/client/firewall/uspfilter/filter_routeacl_test.go new file mode 100644 index 000000000..68572a01c --- /dev/null +++ b/client/firewall/uspfilter/filter_routeacl_test.go @@ -0,0 +1,376 @@ +package uspfilter + +import ( + "net/netip" + "testing" + + "github.com/golang/mock/gomock" + "github.com/google/gopacket/layers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + wgdevice "golang.zx2c4.com/wireguard/device" + + 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/mocks" + "github.com/netbirdio/netbird/client/iface/wgaddr" +) + +// TestAddRouteFilteringReturnsExistingRule verifies that adding the same route +// filtering rule twice returns the same rule ID (idempotent behavior). +func TestAddRouteFilteringReturnsExistingRule(t *testing.T) { + manager := setupTestManager(t) + + sources := []netip.Prefix{ + netip.MustParsePrefix("100.64.1.0/24"), + netip.MustParsePrefix("100.64.2.0/24"), + } + destination := fw.Network{Prefix: netip.MustParsePrefix("192.168.1.0/24")} + + // Add rule first time + rule1, err := manager.AddRouteFiltering( + []byte("policy-1"), + sources, + destination, + fw.ProtocolTCP, + nil, + &fw.Port{Values: []uint16{443}}, + fw.ActionAccept, + ) + require.NoError(t, err) + require.NotNil(t, rule1) + + // Add the same rule again + rule2, err := manager.AddRouteFiltering( + []byte("policy-1"), + sources, + destination, + fw.ProtocolTCP, + nil, + &fw.Port{Values: []uint16{443}}, + fw.ActionAccept, + ) + require.NoError(t, err) + require.NotNil(t, rule2) + + // These should be the same (idempotent) like nftables/iptables implementations + assert.Equal(t, rule1.ID(), rule2.ID(), + "Adding the same rule twice should return the same rule ID (idempotent)") + + manager.mutex.RLock() + ruleCount := len(manager.routeRules) + manager.mutex.RUnlock() + + assert.Equal(t, 2, ruleCount, + "Should have exactly 2 rules (1 user rule + 1 block rule)") +} + +// TestAddRouteFilteringDifferentRulesGetDifferentIDs verifies that rules with +// different parameters get distinct IDs. +func TestAddRouteFilteringDifferentRulesGetDifferentIDs(t *testing.T) { + manager := setupTestManager(t) + + sources := []netip.Prefix{netip.MustParsePrefix("100.64.1.0/24")} + + // Add first rule + rule1, err := manager.AddRouteFiltering( + []byte("policy-1"), + sources, + fw.Network{Prefix: netip.MustParsePrefix("192.168.1.0/24")}, + fw.ProtocolTCP, + nil, + &fw.Port{Values: []uint16{443}}, + fw.ActionAccept, + ) + require.NoError(t, err) + + // Add different rule (different destination) + rule2, err := manager.AddRouteFiltering( + []byte("policy-2"), + sources, + fw.Network{Prefix: netip.MustParsePrefix("192.168.2.0/24")}, // Different! + fw.ProtocolTCP, + nil, + &fw.Port{Values: []uint16{443}}, + fw.ActionAccept, + ) + require.NoError(t, err) + + assert.NotEqual(t, rule1.ID(), rule2.ID(), + "Different rules should have different IDs") + + manager.mutex.RLock() + ruleCount := len(manager.routeRules) + manager.mutex.RUnlock() + + assert.Equal(t, 3, ruleCount, "Should have 3 rules (2 user rules + 1 block rule)") +} + +// TestRouteRuleUpdateDoesNotCauseGap verifies that re-adding the same route +// rule during a network map update does not disrupt existing traffic. +func TestRouteRuleUpdateDoesNotCauseGap(t *testing.T) { + manager := setupTestManager(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( + []byte("policy-1"), + sources, + destination, + fw.ProtocolTCP, + nil, + nil, + fw.ActionAccept, + ) + require.NoError(t, err) + + srcIP := netip.MustParseAddr("100.64.1.5") + dstIP := netip.MustParseAddr("192.168.1.10") + _, pass := manager.routeACLsPass(srcIP, dstIP, layers.LayerTypeTCP, 12345, 443) + require.True(t, pass, "Traffic should pass with rule in place") + + // Re-add same rule (simulates network map update) + rule2, err := manager.AddRouteFiltering( + []byte("policy-1"), + sources, + destination, + fw.ProtocolTCP, + nil, + nil, + fw.ActionAccept, + ) + require.NoError(t, err) + + // Idempotent IDs mean rule1.ID() == rule2.ID(), so the ACL manager + // 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) + require.NoError(t, err) + } + + _, passAfter := manager.routeACLsPass(srcIP, dstIP, layers.LayerTypeTCP, 12345, 443) + assert.True(t, passAfter, + "Traffic should still pass after rule update - no gap should occur") +} + +// TestBlockInvalidRoutedIdempotent verifies that blockInvalidRouted creates +// exactly one drop rule for the WireGuard network prefix, and calling it again +// returns the same rule without duplicating. +func TestBlockInvalidRoutedIdempotent(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") + + ifaceMock := &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + AddressFunc: func() wgaddr.Address { + return wgaddr.Address{ + IP: wgNet.Addr(), + Network: wgNet, + } + }, + GetDeviceFunc: func() *device.FilteredDevice { + return &device.FilteredDevice{Device: dev} + }, + GetWGDeviceFunc: func() *wgdevice.Device { + return &wgdevice.Device{} + }, + } + + manager, err := Create(ifaceMock, false, flowLogger, iface.DefaultMTU) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, manager.Close(nil)) + }) + + // Call blockInvalidRouted directly multiple times + rule1, err := manager.blockInvalidRouted(ifaceMock) + require.NoError(t, err) + require.NotNil(t, rule1) + + rule2, err := manager.blockInvalidRouted(ifaceMock) + require.NoError(t, err) + require.NotNil(t, rule2) + + rule3, err := manager.blockInvalidRouted(ifaceMock) + require.NoError(t, err) + require.NotNil(t, rule3) + + // All should return the same rule + assert.Equal(t, rule1.ID(), rule2.ID(), "Second call should return same rule") + assert.Equal(t, rule2.ID(), rule3.ID(), "Third call should return same rule") + + // Should have exactly 1 route rule + manager.mutex.RLock() + ruleCount := len(manager.routeRules) + manager.mutex.RUnlock() + + assert.Equal(t, 1, ruleCount, "Should have exactly 1 block rule after 3 calls") + + // Verify the rule blocks traffic to the WG network + srcIP := netip.MustParseAddr("10.0.0.1") + dstIP := netip.MustParseAddr("100.64.0.50") + _, pass := manager.routeACLsPass(srcIP, dstIP, layers.LayerTypeTCP, 12345, 80) + assert.False(t, pass, "Block rule should deny traffic to WG prefix") +} + +// TestBlockRuleNotAccumulatedOnRepeatedEnableRouting verifies that calling +// EnableRouting multiple times (as happens on each route update) does not +// accumulate duplicate block rules in the routeRules slice. +func TestBlockRuleNotAccumulatedOnRepeatedEnableRouting(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") + + ifaceMock := &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + AddressFunc: func() wgaddr.Address { + return wgaddr.Address{ + IP: wgNet.Addr(), + Network: wgNet, + } + }, + GetDeviceFunc: func() *device.FilteredDevice { + return &device.FilteredDevice{Device: dev} + }, + GetWGDeviceFunc: func() *wgdevice.Device { + return &wgdevice.Device{} + }, + } + + manager, err := Create(ifaceMock, false, flowLogger, iface.DefaultMTU) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, manager.Close(nil)) + }) + + // Call EnableRouting multiple times (simulating repeated route updates) + for i := 0; i < 5; i++ { + require.NoError(t, manager.EnableRouting()) + } + + manager.mutex.RLock() + ruleCount := len(manager.routeRules) + manager.mutex.RUnlock() + + assert.Equal(t, 1, ruleCount, + "Repeated EnableRouting should not accumulate block rules") +} + +// TestRouteRuleCountStableAcrossUpdates verifies that adding the same route +// rule multiple times does not create duplicate entries. +func TestRouteRuleCountStableAcrossUpdates(t *testing.T) { + manager := setupTestManager(t) + + sources := []netip.Prefix{netip.MustParsePrefix("100.64.1.0/24")} + destination := fw.Network{Prefix: netip.MustParsePrefix("192.168.1.0/24")} + + // Simulate 5 network map updates with the same route rule + for i := 0; i < 5; i++ { + rule, err := manager.AddRouteFiltering( + []byte("policy-1"), + sources, + destination, + fw.ProtocolTCP, + nil, + &fw.Port{Values: []uint16{443}}, + fw.ActionAccept, + ) + require.NoError(t, err) + require.NotNil(t, rule) + } + + manager.mutex.RLock() + ruleCount := len(manager.routeRules) + manager.mutex.RUnlock() + + assert.Equal(t, 2, ruleCount, + "Should have exactly 2 rules (1 user rule + 1 block rule) after 5 updates") +} + +// TestDeleteRouteRuleAfterIdempotentAdd verifies that deleting a route rule +// after adding it multiple times works correctly. +func TestDeleteRouteRuleAfterIdempotentAdd(t *testing.T) { + manager := setupTestManager(t) + + sources := []netip.Prefix{netip.MustParsePrefix("100.64.1.0/24")} + destination := fw.Network{Prefix: netip.MustParsePrefix("192.168.1.0/24")} + + // Add same rule twice + rule1, err := manager.AddRouteFiltering( + []byte("policy-1"), + sources, + destination, + fw.ProtocolTCP, + nil, + nil, + fw.ActionAccept, + ) + require.NoError(t, err) + + rule2, err := manager.AddRouteFiltering( + []byte("policy-1"), + sources, + destination, + fw.ProtocolTCP, + nil, + nil, + fw.ActionAccept, + ) + require.NoError(t, err) + + require.Equal(t, rule1.ID(), rule2.ID(), "Should return same rule ID") + + // Delete using first reference + err = manager.DeleteRouteRule(rule1) + require.NoError(t, err) + + // Verify traffic no longer passes + srcIP := netip.MustParseAddr("100.64.1.5") + dstIP := netip.MustParseAddr("192.168.1.10") + _, pass := manager.routeACLsPass(srcIP, dstIP, layers.LayerTypeTCP, 12345, 443) + assert.False(t, pass, "Traffic should not pass after rule deletion") +} + +func setupTestManager(t *testing.T) *Manager { + t.Helper() + + ctrl := gomock.NewController(t) + dev := mocks.NewMockDevice(ctrl) + dev.EXPECT().MTU().Return(1500, nil).AnyTimes() + + wgNet := netip.MustParsePrefix("100.64.0.1/16") + + ifaceMock := &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + AddressFunc: func() wgaddr.Address { + return wgaddr.Address{ + IP: wgNet.Addr(), + Network: wgNet, + } + }, + GetDeviceFunc: func() *device.FilteredDevice { + return &device.FilteredDevice{Device: dev} + }, + GetWGDeviceFunc: func() *wgdevice.Device { + return &wgdevice.Device{} + }, + } + + manager, err := Create(ifaceMock, false, flowLogger, iface.DefaultMTU) + require.NoError(t, err) + require.NoError(t, manager.EnableRouting()) + + t.Cleanup(func() { + require.NoError(t, manager.Close(nil)) + }) + + return manager +} diff --git a/client/firewall/uspfilter/filter_test.go b/client/firewall/uspfilter/filter_test.go index c6a4ebeb8..55a8e723c 100644 --- a/client/firewall/uspfilter/filter_test.go +++ b/client/firewall/uspfilter/filter_test.go @@ -263,6 +263,158 @@ func TestAddUDPPacketHook(t *testing.T) { } } +// TestPeerRuleLifecycleDenyRules verifies that deny rules are correctly added +// to the deny map and can be cleanly deleted without leaving orphans. +func TestPeerRuleLifecycleDenyRules(t *testing.T) { + ifaceMock := &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + } + + m, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + require.NoError(t, err) + defer func() { + require.NoError(t, m.Close(nil)) + }() + + ip := net.ParseIP("192.168.1.1") + 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, "") + require.NoError(t, err) + + rule2, err := m.AddPeerFiltering(nil, ip, fw.ProtocolTCP, nil, + &fw.Port{Values: []uint16{80}}, fw.ActionDrop, "") + require.NoError(t, err) + + m.mutex.RLock() + denyCount := len(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]) + require.NoError(t, err) + + m.mutex.RLock() + denyCount = len(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]) + require.NoError(t, err) + + m.mutex.RLock() + _, exists := m.incomingDenyRules[addr] + m.mutex.RUnlock() + require.False(t, exists, "Deny rules IP entry should be cleaned up when empty") +} + +// TestPeerRuleAddAndDeleteDontLeak verifies that repeatedly adding and deleting +// peer rules (simulating network map updates) does not leak rules in the maps. +func TestPeerRuleAddAndDeleteDontLeak(t *testing.T) { + ifaceMock := &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + } + + m, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + require.NoError(t, err) + defer func() { + require.NoError(t, m.Close(nil)) + }() + + ip := net.ParseIP("192.168.1.1") + addr := netip.MustParseAddr("192.168.1.1") + + // 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, "") + require.NoError(t, err) + + // Add an allow rule + allowRules, err := m.AddPeerFiltering(nil, ip, 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)) + } + } + + m.mutex.RLock() + denyCount := len(m.incomingDenyRules[addr]) + allowCount := len(m.incomingRules[addr]) + m.mutex.RUnlock() + + require.Equal(t, 0, denyCount, "No deny rules should remain after cleanup") + require.Equal(t, 0, allowCount, "No allow rules should remain after cleanup") +} + +// TestMixedAllowDenyRulesSameIP verifies that allow and deny rules for the same +// IP are stored in separate maps and don't interfere with each other. +func TestMixedAllowDenyRulesSameIP(t *testing.T) { + ifaceMock := &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + } + + m, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + require.NoError(t, err) + defer func() { + require.NoError(t, m.Close(nil)) + }() + + 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, "") + 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, "") + require.NoError(t, err) + + addr := netip.MustParseAddr("192.168.1.1") + m.mutex.RLock() + allowCount := len(m.incomingRules[addr]) + denyCount := len(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]) + require.NoError(t, err) + + m.mutex.RLock() + denyCountAfter := len(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]) + require.NoError(t, err) + + m.mutex.RLock() + _, denyExists := m.incomingDenyRules[addr] + _, allowExists := m.incomingRules[addr] + m.mutex.RUnlock() + + require.False(t, denyExists, "Deny rules should be empty") + require.False(t, allowExists, "Allow rules should be empty") +} + func TestManagerReset(t *testing.T) { ifaceMock := &IFaceMock{ SetFilterFunc: func(device.PacketFilter) error { return nil }, diff --git a/client/firewall/uspfilter/log/log.go b/client/firewall/uspfilter/log/log.go index 66308defc..c6ca55e70 100644 --- a/client/firewall/uspfilter/log/log.go +++ b/client/firewall/uspfilter/log/log.go @@ -5,6 +5,8 @@ import ( "context" "fmt" "io" + "os" + "strconv" "sync" "sync/atomic" "time" @@ -16,9 +18,18 @@ const ( maxBatchSize = 1024 * 16 maxMessageSize = 1024 * 2 defaultFlushInterval = 2 * time.Second - logChannelSize = 1000 + defaultLogChanSize = 1000 ) +func getLogChannelSize() int { + if v := os.Getenv("NB_USPFILTER_LOG_BUFFER"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + return n + } + } + return defaultLogChanSize +} + type Level uint32 const ( @@ -69,7 +80,7 @@ type Logger struct { func NewFromLogrus(logrusLogger *log.Logger) *Logger { l := &Logger{ output: logrusLogger.Out, - msgChannel: make(chan logMessage, logChannelSize), + msgChannel: make(chan logMessage, getLogChannelSize()), shutdown: make(chan struct{}), bufPool: sync.Pool{ New: func() any { diff --git a/client/firewall/uspfilter/nat.go b/client/firewall/uspfilter/nat.go index 13567872e..597f892cf 100644 --- a/client/firewall/uspfilter/nat.go +++ b/client/firewall/uspfilter/nat.go @@ -358,9 +358,9 @@ func incrementalUpdate(oldChecksum uint16, oldBytes, newBytes []byte) uint16 { // Fast path for IPv4 addresses (4 bytes) - most common case if len(oldBytes) == 4 && len(newBytes) == 4 { sum += uint32(^binary.BigEndian.Uint16(oldBytes[0:2])) - sum += uint32(^binary.BigEndian.Uint16(oldBytes[2:4])) + sum += uint32(^binary.BigEndian.Uint16(oldBytes[2:4])) //nolint:gosec // length checked above sum += uint32(binary.BigEndian.Uint16(newBytes[0:2])) - sum += uint32(binary.BigEndian.Uint16(newBytes[2:4])) + sum += uint32(binary.BigEndian.Uint16(newBytes[2:4])) //nolint:gosec // length checked above } else { // Fallback for other lengths for i := 0; i < len(oldBytes)-1; i += 2 { diff --git a/client/iface/configurer/uapi.go b/client/iface/configurer/uapi.go index f85c7852a..d9bd9bfab 100644 --- a/client/iface/configurer/uapi.go +++ b/client/iface/configurer/uapi.go @@ -5,20 +5,18 @@ package configurer import ( "net" - log "github.com/sirupsen/logrus" "golang.zx2c4.com/wireguard/ipc" ) func openUAPI(deviceName string) (net.Listener, error) { uapiSock, err := ipc.UAPIOpen(deviceName) if err != nil { - log.Errorf("failed to open uapi socket: %v", err) return nil, err } listener, err := ipc.UAPIListen(deviceName, uapiSock) if err != nil { - log.Errorf("failed to listen on uapi socket: %v", err) + _ = uapiSock.Close() return nil, err } diff --git a/client/iface/configurer/usp.go b/client/iface/configurer/usp.go index 1298c609d..e3a96590c 100644 --- a/client/iface/configurer/usp.go +++ b/client/iface/configurer/usp.go @@ -54,6 +54,14 @@ func NewUSPConfigurer(device *device.Device, deviceName string, activityRecorder return wgCfg } +func NewUSPConfigurerNoUAPI(device *device.Device, deviceName string, activityRecorder *bind.ActivityRecorder) *WGUSPConfigurer { + return &WGUSPConfigurer{ + device: device, + deviceName: deviceName, + activityRecorder: activityRecorder, + } +} + func (c *WGUSPConfigurer) ConfigureInterface(privateKey string, port int) error { log.Debugf("adding Wireguard private key") key, err := wgtypes.ParseKey(privateKey) diff --git a/client/iface/device/device_filter.go b/client/iface/device/device_filter.go index 015f71ff4..708f38d26 100644 --- a/client/iface/device/device_filter.go +++ b/client/iface/device/device_filter.go @@ -29,8 +29,9 @@ type PacketFilter interface { type FilteredDevice struct { tun.Device - filter PacketFilter - mutex sync.RWMutex + filter PacketFilter + mutex sync.RWMutex + closeOnce sync.Once } // newDeviceFilter constructor function @@ -40,6 +41,20 @@ func newDeviceFilter(device tun.Device) *FilteredDevice { } } +// Close closes the underlying tun device exactly once. +// wireguard-go's netTun.Close() panics on double-close due to a bare close(channel), +// and multiple code paths can trigger Close on the same device. +func (d *FilteredDevice) Close() error { + var err error + d.closeOnce.Do(func() { + err = d.Device.Close() + }) + if err != nil { + return err + } + return nil +} + // Read wraps read method with filtering feature func (d *FilteredDevice) Read(bufs [][]byte, sizes []int, offset int) (n int, err error) { if n, err = d.Device.Read(bufs, sizes, offset); err != nil { diff --git a/client/iface/device/device_netstack.go b/client/iface/device/device_netstack.go index 40d8fdac8..1a92b148f 100644 --- a/client/iface/device/device_netstack.go +++ b/client/iface/device/device_netstack.go @@ -79,10 +79,12 @@ func (t *TunNetstackDevice) create() (WGConfigurer, error) { device.NewLogger(wgLogLevel(), "[netbird] "), ) - t.configurer = configurer.NewUSPConfigurer(t.device, t.name, t.bind.ActivityRecorder()) + t.configurer = configurer.NewUSPConfigurerNoUAPI(t.device, t.name, t.bind.ActivityRecorder()) err = t.configurer.ConfigureInterface(t.key, t.port) if err != nil { - _ = tunIface.Close() + if cErr := tunIface.Close(); cErr != nil { + log.Debugf("failed to close tun device: %v", cErr) + } return nil, fmt.Errorf("error configuring interface: %s", err) } diff --git a/client/iface/iface.go b/client/iface/iface.go index e5623c979..9b331d68c 100644 --- a/client/iface/iface.go +++ b/client/iface/iface.go @@ -18,6 +18,7 @@ import ( "github.com/netbirdio/netbird/client/errors" "github.com/netbirdio/netbird/client/iface/configurer" "github.com/netbirdio/netbird/client/iface/device" + nbnetstack "github.com/netbirdio/netbird/client/iface/netstack" "github.com/netbirdio/netbird/client/iface/udpmux" "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/iface/wgproxy" @@ -228,6 +229,10 @@ func (w *WGIface) Close() error { result = multierror.Append(result, fmt.Errorf("failed to close wireguard interface %s: %w", w.Name(), err)) } + if nbnetstack.IsEnabled() { + return errors.FormatErrorOrNil(result) + } + if err := w.waitUntilRemoved(); err != nil { log.Warnf("failed to remove WireGuard interface %s: %v", w.Name(), err) if err := w.Destroy(); err != nil { diff --git a/client/iface/netstack/tun.go b/client/iface/netstack/tun.go index b2506b50d..346ae29ec 100644 --- a/client/iface/netstack/tun.go +++ b/client/iface/netstack/tun.go @@ -66,7 +66,7 @@ func (t *NetStackTun) Create() (tun.Device, *netstack.Net, error) { } }() - return nsTunDev, tunNet, nil + return t.tundev, tunNet, nil } func (t *NetStackTun) Close() error { diff --git a/client/internal/acl/manager_test.go b/client/internal/acl/manager_test.go index 4bc0fd800..bd7adfaef 100644 --- a/client/internal/acl/manager_test.go +++ b/client/internal/acl/manager_test.go @@ -189,6 +189,212 @@ func TestDefaultManagerStateless(t *testing.T) { }) } +// TestDenyRulesNotAccumulatedOnRepeatedApply verifies that applying the same +// deny rules repeatedly does not accumulate duplicate rules in the uspfilter. +// This tests the full ACL manager -> uspfilter integration. +func TestDenyRulesNotAccumulatedOnRepeatedApply(t *testing.T) { + t.Setenv("NB_WG_KERNEL_DISABLED", "true") + + networkMap := &mgmProto.NetworkMap{ + FirewallRules: []*mgmProto.FirewallRule{ + { + PeerIP: "10.93.0.1", + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_DROP, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "22", + }, + { + PeerIP: "10.93.0.2", + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_DROP, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "80", + }, + { + PeerIP: "10.93.0.3", + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "443", + }, + }, + FirewallRulesIsEmpty: false, + } + + 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() + + fw, err := firewall.NewFirewall(ifaceMock, nil, flowLogger, false, iface.DefaultMTU) + require.NoError(t, err) + defer func() { + require.NoError(t, fw.Close(nil)) + }() + + acl := NewDefaultManager(fw) + + // Apply the same rules 5 times (simulating repeated network map updates) + for i := 0; i < 5; i++ { + acl.ApplyFiltering(networkMap, false) + } + + // The ACL manager should track exactly 3 rule pairs (2 deny + 1 accept inbound) + assert.Equal(t, 3, len(acl.peerRulesPairs), + "Should have exactly 3 rule pairs after 5 identical updates") +} + +// TestDenyRulesCleanedUpOnRemoval verifies that deny rules are properly cleaned +// up when they're removed from the network map in a subsequent update. +func TestDenyRulesCleanedUpOnRemoval(t *testing.T) { + t.Setenv("NB_WG_KERNEL_DISABLED", "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() + + fw, err := firewall.NewFirewall(ifaceMock, nil, flowLogger, false, iface.DefaultMTU) + require.NoError(t, err) + defer func() { + require.NoError(t, fw.Close(nil)) + }() + + acl := NewDefaultManager(fw) + + // First update: add deny and accept rules + networkMap1 := &mgmProto.NetworkMap{ + FirewallRules: []*mgmProto.FirewallRule{ + { + PeerIP: "10.93.0.1", + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_DROP, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "22", + }, + { + PeerIP: "10.93.0.2", + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "443", + }, + }, + FirewallRulesIsEmpty: false, + } + + acl.ApplyFiltering(networkMap1, false) + assert.Equal(t, 2, len(acl.peerRulesPairs), "Should have 2 rules after first update") + + // Second update: remove the deny rule, keep only accept + networkMap2 := &mgmProto.NetworkMap{ + FirewallRules: []*mgmProto.FirewallRule{ + { + PeerIP: "10.93.0.2", + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "443", + }, + }, + FirewallRulesIsEmpty: false, + } + + acl.ApplyFiltering(networkMap2, false) + assert.Equal(t, 1, len(acl.peerRulesPairs), + "Should have 1 rule after removing deny rule") + + // Third update: remove all rules + networkMap3 := &mgmProto.NetworkMap{ + FirewallRules: []*mgmProto.FirewallRule{}, + FirewallRulesIsEmpty: true, + } + + acl.ApplyFiltering(networkMap3, false) + assert.Equal(t, 0, len(acl.peerRulesPairs), + "Should have 0 rules after removing all rules") +} + +// TestRuleUpdateChangingAction verifies that when a rule's action changes from +// accept to deny (or vice versa), the old rule is properly removed and the new +// one added without leaking. +func TestRuleUpdateChangingAction(t *testing.T) { + t.Setenv("NB_WG_KERNEL_DISABLED", "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() + + fw, err := firewall.NewFirewall(ifaceMock, nil, flowLogger, false, iface.DefaultMTU) + require.NoError(t, err) + defer func() { + require.NoError(t, fw.Close(nil)) + }() + + acl := NewDefaultManager(fw) + + // First update: accept rule + networkMap := &mgmProto.NetworkMap{ + FirewallRules: []*mgmProto.FirewallRule{ + { + PeerIP: "10.93.0.1", + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "22", + }, + }, + FirewallRulesIsEmpty: false, + } + acl.ApplyFiltering(networkMap, false) + assert.Equal(t, 1, len(acl.peerRulesPairs)) + + // Second update: change to deny (same IP/port/proto, different action) + networkMap.FirewallRules = []*mgmProto.FirewallRule{ + { + PeerIP: "10.93.0.1", + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_DROP, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "22", + }, + } + acl.ApplyFiltering(networkMap, false) + + // Should still have exactly 1 rule (the old accept removed, new deny added) + assert.Equal(t, 1, len(acl.peerRulesPairs), + "Changing action should result in exactly 1 rule, not 2") +} + func TestPortInfoEmpty(t *testing.T) { tests := []struct { name string diff --git a/client/internal/connect.go b/client/internal/connect.go index 7fc3c9a96..ccd7b6c33 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -20,14 +20,15 @@ import ( "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/iface/device" + "github.com/netbirdio/netbird/client/iface/netstack" "github.com/netbirdio/netbird/client/internal/dns" "github.com/netbirdio/netbird/client/internal/listener" "github.com/netbirdio/netbird/client/internal/peer" "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/updatemanager" - "github.com/netbirdio/netbird/client/internal/updatemanager/installer" + "github.com/netbirdio/netbird/client/internal/updater" + "github.com/netbirdio/netbird/client/internal/updater/installer" nbnet "github.com/netbirdio/netbird/client/net" cProto "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/ssh" @@ -43,13 +44,13 @@ import ( ) type ConnectClient struct { - ctx context.Context - config *profilemanager.Config - statusRecorder *peer.Status - doInitialAutoUpdate bool + ctx context.Context + config *profilemanager.Config + statusRecorder *peer.Status - engine *Engine - engineMutex sync.Mutex + engine *Engine + engineMutex sync.Mutex + updateManager *updater.Manager persistSyncResponse bool } @@ -58,17 +59,19 @@ func NewConnectClient( ctx context.Context, config *profilemanager.Config, statusRecorder *peer.Status, - doInitalAutoUpdate bool, ) *ConnectClient { return &ConnectClient{ - ctx: ctx, - config: config, - statusRecorder: statusRecorder, - doInitialAutoUpdate: doInitalAutoUpdate, - engineMutex: sync.Mutex{}, + ctx: ctx, + config: config, + statusRecorder: statusRecorder, + engineMutex: sync.Mutex{}, } } +func (c *ConnectClient) SetUpdateManager(um *updater.Manager) { + c.updateManager = um +} + // Run with main logic. func (c *ConnectClient) Run(runningChan chan struct{}, logPath string) error { return c.run(MobileDependency{}, runningChan, logPath) @@ -186,14 +189,13 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan stateManager := statemanager.New(path) stateManager.RegisterState(&sshconfig.ShutdownState{}) - updateManager, err := updatemanager.NewManager(c.statusRecorder, stateManager) - if err == nil { - updateManager.CheckUpdateSuccess(c.ctx) + if c.updateManager != nil { + c.updateManager.CheckUpdateSuccess(c.ctx) + } - inst := installer.New() - if err := inst.CleanUpInstallerFiles(); err != nil { - log.Errorf("failed to clean up temporary installer file: %v", err) - } + inst := installer.New() + if err := inst.CleanUpInstallerFiles(); err != nil { + log.Errorf("failed to clean up temporary installer file: %v", err) } defer c.statusRecorder.ClientStop() @@ -244,7 +246,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan localPeerState := peer.LocalPeerState{ IP: loginResp.GetPeerConfig().GetAddress(), PubKey: myPrivateKey.PublicKey().String(), - KernelInterface: device.WireGuardModuleIsLoaded(), + KernelInterface: device.WireGuardModuleIsLoaded() && !netstack.IsEnabled(), FQDN: loginResp.GetPeerConfig().GetFqdn(), } c.statusRecorder.UpdateLocalPeerState(localPeerState) @@ -307,7 +309,15 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan checks := loginResp.GetChecks() c.engineMutex.Lock() - engine := NewEngine(engineCtx, cancel, signalClient, mgmClient, relayManager, engineConfig, mobileDependency, c.statusRecorder, checks, stateManager) + engine := NewEngine(engineCtx, cancel, engineConfig, EngineServices{ + SignalClient: signalClient, + MgmClient: mgmClient, + RelayManager: relayManager, + StatusRecorder: c.statusRecorder, + Checks: checks, + StateManager: stateManager, + UpdateManager: c.updateManager, + }, mobileDependency) engine.SetSyncResponsePersistence(c.persistSyncResponse) c.engine = engine c.engineMutex.Unlock() @@ -317,21 +327,15 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan return wrapErr(err) } - if loginResp.PeerConfig != nil && loginResp.PeerConfig.AutoUpdate != nil { - // AutoUpdate will be true when the user click on "Connect" menu on the UI - if c.doInitialAutoUpdate { - log.Infof("start engine by ui, run auto-update check") - c.engine.InitialUpdateHandling(loginResp.PeerConfig.AutoUpdate) - c.doInitialAutoUpdate = false - } - } - log.Infof("Netbird engine started, the IP is: %s", peerConfig.GetAddress()) state.Set(StatusConnected) if runningChan != nil { - close(runningChan) - runningChan = nil + select { + case <-runningChan: + default: + close(runningChan) + } } <-engineCtx.Done() diff --git a/client/internal/daemonaddr/resolve.go b/client/internal/daemonaddr/resolve.go new file mode 100644 index 000000000..b445696ab --- /dev/null +++ b/client/internal/daemonaddr/resolve.go @@ -0,0 +1,60 @@ +//go:build !windows && !ios && !android + +package daemonaddr + +import ( + "os" + "path/filepath" + "strings" + + log "github.com/sirupsen/logrus" +) + +var scanDir = "/var/run/netbird" + +// setScanDir overrides the scan directory (used by tests). +func setScanDir(dir string) { + scanDir = dir +} + +// ResolveUnixDaemonAddr checks whether the default Unix socket exists and, if not, +// scans /var/run/netbird/ for a single .sock file to use instead. This handles the +// mismatch between the netbird@.service template (which places the socket under +// /var/run/netbird/.sock) and the CLI default (/var/run/netbird.sock). +func ResolveUnixDaemonAddr(addr string) string { + if !strings.HasPrefix(addr, "unix://") { + return addr + } + + sockPath := strings.TrimPrefix(addr, "unix://") + if _, err := os.Stat(sockPath); err == nil { + return addr + } + + entries, err := os.ReadDir(scanDir) + if err != nil { + return addr + } + + var found []string + for _, e := range entries { + if e.IsDir() { + continue + } + if strings.HasSuffix(e.Name(), ".sock") { + found = append(found, filepath.Join(scanDir, e.Name())) + } + } + + switch len(found) { + case 1: + resolved := "unix://" + found[0] + log.Debugf("Default daemon socket not found, using discovered socket: %s", resolved) + return resolved + case 0: + return addr + default: + log.Warnf("Default daemon socket not found and multiple sockets discovered in %s; pass --daemon-addr explicitly", scanDir) + return addr + } +} diff --git a/client/internal/daemonaddr/resolve_stub.go b/client/internal/daemonaddr/resolve_stub.go new file mode 100644 index 000000000..080b7171a --- /dev/null +++ b/client/internal/daemonaddr/resolve_stub.go @@ -0,0 +1,8 @@ +//go:build windows || ios || android + +package daemonaddr + +// ResolveUnixDaemonAddr is a no-op on platforms that don't use Unix sockets. +func ResolveUnixDaemonAddr(addr string) string { + return addr +} diff --git a/client/internal/daemonaddr/resolve_test.go b/client/internal/daemonaddr/resolve_test.go new file mode 100644 index 000000000..3df67708a --- /dev/null +++ b/client/internal/daemonaddr/resolve_test.go @@ -0,0 +1,121 @@ +//go:build !windows && !ios && !android + +package daemonaddr + +import ( + "os" + "path/filepath" + "testing" +) + +// createSockFile creates a regular file with a .sock extension. +// ResolveUnixDaemonAddr uses os.Stat (not net.Dial), so a regular file is +// sufficient and avoids Unix socket path-length limits on macOS. +func createSockFile(t *testing.T, path string) { + t.Helper() + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatalf("failed to create test sock file at %s: %v", path, err) + } +} + +func TestResolveUnixDaemonAddr_DefaultExists(t *testing.T) { + tmp := t.TempDir() + sock := filepath.Join(tmp, "netbird.sock") + createSockFile(t, sock) + + addr := "unix://" + sock + got := ResolveUnixDaemonAddr(addr) + if got != addr { + t.Errorf("expected %s, got %s", addr, got) + } +} + +func TestResolveUnixDaemonAddr_SingleDiscovered(t *testing.T) { + tmp := t.TempDir() + + // Default socket does not exist + defaultAddr := "unix://" + filepath.Join(tmp, "netbird.sock") + + // Create a scan dir with one socket + sd := filepath.Join(tmp, "netbird") + if err := os.MkdirAll(sd, 0o755); err != nil { + t.Fatal(err) + } + instanceSock := filepath.Join(sd, "main.sock") + createSockFile(t, instanceSock) + + origScanDir := scanDir + setScanDir(sd) + t.Cleanup(func() { setScanDir(origScanDir) }) + + got := ResolveUnixDaemonAddr(defaultAddr) + expected := "unix://" + instanceSock + if got != expected { + t.Errorf("expected %s, got %s", expected, got) + } +} + +func TestResolveUnixDaemonAddr_MultipleDiscovered(t *testing.T) { + tmp := t.TempDir() + + defaultAddr := "unix://" + filepath.Join(tmp, "netbird.sock") + + sd := filepath.Join(tmp, "netbird") + if err := os.MkdirAll(sd, 0o755); err != nil { + t.Fatal(err) + } + createSockFile(t, filepath.Join(sd, "main.sock")) + createSockFile(t, filepath.Join(sd, "other.sock")) + + origScanDir := scanDir + setScanDir(sd) + t.Cleanup(func() { setScanDir(origScanDir) }) + + got := ResolveUnixDaemonAddr(defaultAddr) + if got != defaultAddr { + t.Errorf("expected original %s, got %s", defaultAddr, got) + } +} + +func TestResolveUnixDaemonAddr_NoSocketsFound(t *testing.T) { + tmp := t.TempDir() + + defaultAddr := "unix://" + filepath.Join(tmp, "netbird.sock") + + sd := filepath.Join(tmp, "netbird") + if err := os.MkdirAll(sd, 0o755); err != nil { + t.Fatal(err) + } + + origScanDir := scanDir + setScanDir(sd) + t.Cleanup(func() { setScanDir(origScanDir) }) + + got := ResolveUnixDaemonAddr(defaultAddr) + if got != defaultAddr { + t.Errorf("expected original %s, got %s", defaultAddr, got) + } +} + +func TestResolveUnixDaemonAddr_NonUnixAddr(t *testing.T) { + addr := "tcp://127.0.0.1:41731" + got := ResolveUnixDaemonAddr(addr) + if got != addr { + t.Errorf("expected %s, got %s", addr, got) + } +} + +func TestResolveUnixDaemonAddr_ScanDirMissing(t *testing.T) { + tmp := t.TempDir() + + defaultAddr := "unix://" + filepath.Join(tmp, "netbird.sock") + + origScanDir := scanDir + setScanDir(filepath.Join(tmp, "nonexistent")) + t.Cleanup(func() { setScanDir(origScanDir) }) + + got := ResolveUnixDaemonAddr(defaultAddr) + if got != defaultAddr { + t.Errorf("expected original %s, got %s", defaultAddr, got) + } +} diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go index 0f8243e7a..f0f399bef 100644 --- a/client/internal/debug/debug.go +++ b/client/internal/debug/debug.go @@ -27,7 +27,7 @@ import ( "github.com/netbirdio/netbird/client/anonymize" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/profilemanager" - "github.com/netbirdio/netbird/client/internal/updatemanager/installer" + "github.com/netbirdio/netbird/client/internal/updater/installer" nbstatus "github.com/netbirdio/netbird/client/status" mgmProto "github.com/netbirdio/netbird/shared/management/proto" "github.com/netbirdio/netbird/util" diff --git a/client/internal/dns/handler_chain_test.go b/client/internal/dns/handler_chain_test.go index 72c0004d5..fa9525069 100644 --- a/client/internal/dns/handler_chain_test.go +++ b/client/internal/dns/handler_chain_test.go @@ -112,6 +112,54 @@ func TestHandlerChain_ServeDNS_DomainMatching(t *testing.T) { matchSubdomains: false, shouldMatch: false, }, + { + name: "single letter TLD exact match", + handlerDomain: "example.x.", + queryDomain: "example.x.", + isWildcard: false, + matchSubdomains: false, + shouldMatch: true, + }, + { + name: "single letter TLD subdomain match", + handlerDomain: "example.x.", + queryDomain: "sub.example.x.", + isWildcard: false, + matchSubdomains: true, + shouldMatch: true, + }, + { + name: "single letter TLD wildcard match", + handlerDomain: "*.example.x.", + queryDomain: "sub.example.x.", + isWildcard: true, + matchSubdomains: false, + shouldMatch: true, + }, + { + name: "two letter domain labels", + handlerDomain: "a.b.", + queryDomain: "a.b.", + isWildcard: false, + matchSubdomains: false, + shouldMatch: true, + }, + { + name: "single character domain", + handlerDomain: "x.", + queryDomain: "x.", + isWildcard: false, + matchSubdomains: false, + shouldMatch: true, + }, + { + name: "single character domain with subdomain match", + handlerDomain: "x.", + queryDomain: "sub.x.", + isWildcard: false, + matchSubdomains: true, + shouldMatch: true, + }, } for _, tt := range tests { diff --git a/client/internal/dns/host_darwin.go b/client/internal/dns/host_darwin.go index 71badf0d4..b3908f163 100644 --- a/client/internal/dns/host_darwin.go +++ b/client/internal/dns/host_darwin.go @@ -9,9 +9,13 @@ import ( "io" "net/netip" "os/exec" + "slices" "strconv" "strings" + "sync" + "github.com/hashicorp/go-multierror" + nberrors "github.com/netbirdio/netbird/client/errors" log "github.com/sirupsen/logrus" "golang.org/x/exp/maps" @@ -20,6 +24,7 @@ import ( const ( netbirdDNSStateKeyFormat = "State:/Network/Service/NetBird-%s/DNS" + netbirdDNSStateKeyIndexedFormat = "State:/Network/Service/NetBird-%s-%d/DNS" globalIPv4State = "State:/Network/Global/IPv4" primaryServiceStateKeyFormat = "State:/Network/Service/%s/DNS" keySupplementalMatchDomains = "SupplementalMatchDomains" @@ -33,11 +38,22 @@ const ( searchSuffix = "Search" matchSuffix = "Match" localSuffix = "Local" + + // maxDomainsPerResolverEntry is the max number of domains per scutil resolver key. + // scutil's d.add has maxArgs=101 (key + * + 99 values), so 99 is the hard cap. + maxDomainsPerResolverEntry = 50 + + // maxDomainBytesPerResolverEntry is the max total bytes of domain strings per key. + // scutil has an undocumented ~2048 byte value buffer; we stay well under it. + maxDomainBytesPerResolverEntry = 1500 ) type systemConfigurator struct { createdKeys map[string]struct{} systemDNSSettings SystemDNSSettings + + mu sync.RWMutex + origNameservers []netip.Addr } func newHostManager() (*systemConfigurator, error) { @@ -79,28 +95,23 @@ func (s *systemConfigurator) applyDNSConfig(config HostDNSConfig, stateManager * searchDomains = append(searchDomains, strings.TrimSuffix(""+dConf.Domain, ".")) } - matchKey := getKeyWithInput(netbirdDNSStateKeyFormat, matchSuffix) - var err error - if len(matchDomains) != 0 { - err = s.addMatchDomains(matchKey, strings.Join(matchDomains, " "), config.ServerIP, config.ServerPort) - } else { - log.Infof("removing match domains from the system") - err = s.removeKeyFromSystemConfig(matchKey) + if err := s.removeKeysContaining(matchSuffix); err != nil { + log.Warnf("failed to remove old match keys: %v", err) } - if err != nil { - return fmt.Errorf("add match domains: %w", err) + if len(matchDomains) != 0 { + if err := s.addBatchedDomains(matchSuffix, matchDomains, config.ServerIP, config.ServerPort, false); err != nil { + return fmt.Errorf("add match domains: %w", err) + } } s.updateState(stateManager) - searchKey := getKeyWithInput(netbirdDNSStateKeyFormat, searchSuffix) - if len(searchDomains) != 0 { - err = s.addSearchDomains(searchKey, strings.Join(searchDomains, " "), config.ServerIP, config.ServerPort) - } else { - log.Infof("removing search domains from the system") - err = s.removeKeyFromSystemConfig(searchKey) + if err := s.removeKeysContaining(searchSuffix); err != nil { + log.Warnf("failed to remove old search keys: %v", err) } - if err != nil { - return fmt.Errorf("add search domains: %w", err) + if len(searchDomains) != 0 { + if err := s.addBatchedDomains(searchSuffix, searchDomains, config.ServerIP, config.ServerPort, true); err != nil { + return fmt.Errorf("add search domains: %w", err) + } } s.updateState(stateManager) @@ -144,8 +155,7 @@ func (s *systemConfigurator) restoreHostDNS() error { func (s *systemConfigurator) getRemovableKeysWithDefaults() []string { if len(s.createdKeys) == 0 { - // return defaults for startup calls - return []string{getKeyWithInput(netbirdDNSStateKeyFormat, searchSuffix), getKeyWithInput(netbirdDNSStateKeyFormat, matchSuffix)} + return s.discoverExistingKeys() } keys := make([]string, 0, len(s.createdKeys)) @@ -155,6 +165,47 @@ func (s *systemConfigurator) getRemovableKeysWithDefaults() []string { return keys } +// discoverExistingKeys probes scutil for all NetBird DNS keys that may exist. +// This handles the case where createdKeys is empty (e.g., state file lost after unclean shutdown). +func (s *systemConfigurator) discoverExistingKeys() []string { + dnsKeys, err := getSystemDNSKeys() + if err != nil { + log.Errorf("failed to get system DNS keys: %v", err) + return nil + } + + var keys []string + + for _, suffix := range []string{searchSuffix, matchSuffix, localSuffix} { + key := getKeyWithInput(netbirdDNSStateKeyFormat, suffix) + if strings.Contains(dnsKeys, key) { + keys = append(keys, key) + } + } + + for _, suffix := range []string{searchSuffix, matchSuffix} { + for i := 0; ; i++ { + key := fmt.Sprintf(netbirdDNSStateKeyIndexedFormat, suffix, i) + if !strings.Contains(dnsKeys, key) { + break + } + keys = append(keys, key) + } + } + + return keys +} + +// getSystemDNSKeys gets all DNS keys +func getSystemDNSKeys() (string, error) { + command := "list .*DNS\nquit\n" + out, err := runSystemConfigCommand(command) + if err != nil { + return "", err + } + return string(out), nil +} + func (s *systemConfigurator) removeKeyFromSystemConfig(key string) error { line := buildRemoveKeyOperation(key) _, err := runSystemConfigCommand(wrapCommand(line)) @@ -179,12 +230,11 @@ func (s *systemConfigurator) addLocalDNS() error { return nil } - if err := s.addSearchDomains( - localKey, - strings.Join(s.systemDNSSettings.Domains, " "), s.systemDNSSettings.ServerIP, s.systemDNSSettings.ServerPort, - ); err != nil { - return fmt.Errorf("add search domains: %w", err) + domainsStr := strings.Join(s.systemDNSSettings.Domains, " ") + if err := s.addDNSState(localKey, domainsStr, s.systemDNSSettings.ServerIP, s.systemDNSSettings.ServerPort, true); err != nil { + return fmt.Errorf("add local dns state: %w", err) } + s.createdKeys[localKey] = struct{}{} return nil } @@ -218,6 +268,7 @@ func (s *systemConfigurator) getSystemDNSSettings() (SystemDNSSettings, error) { } var dnsSettings SystemDNSSettings + var serverAddresses []netip.Addr inSearchDomainsArray := false inServerAddressesArray := false @@ -244,9 +295,12 @@ func (s *systemConfigurator) getSystemDNSSettings() (SystemDNSSettings, error) { dnsSettings.Domains = append(dnsSettings.Domains, searchDomain) } else if inServerAddressesArray { address := strings.Split(line, " : ")[1] - if ip, err := netip.ParseAddr(address); err == nil && ip.Is4() { - dnsSettings.ServerIP = ip.Unmap() - inServerAddressesArray = false // Stop reading after finding the first IPv4 address + if ip, err := netip.ParseAddr(address); err == nil && !ip.IsUnspecified() { + ip = ip.Unmap() + serverAddresses = append(serverAddresses, ip) + if !dnsSettings.ServerIP.IsValid() && ip.Is4() { + dnsSettings.ServerIP = ip + } } } } @@ -258,31 +312,90 @@ func (s *systemConfigurator) getSystemDNSSettings() (SystemDNSSettings, error) { // default to 53 port dnsSettings.ServerPort = DefaultPort + s.mu.Lock() + s.origNameservers = serverAddresses + s.mu.Unlock() + return dnsSettings, nil } -func (s *systemConfigurator) addSearchDomains(key, domains string, ip netip.Addr, port int) error { - err := s.addDNSState(key, domains, ip, port, true) - if err != nil { - return fmt.Errorf("add dns state: %w", err) - } - - log.Infof("added %d search domains to the state. Domain list: %s", len(strings.Split(domains, " ")), domains) - - s.createdKeys[key] = struct{}{} - - return nil +func (s *systemConfigurator) getOriginalNameservers() []netip.Addr { + s.mu.RLock() + defer s.mu.RUnlock() + return slices.Clone(s.origNameservers) } -func (s *systemConfigurator) addMatchDomains(key, domains string, dnsServer netip.Addr, port int) error { - err := s.addDNSState(key, domains, dnsServer, port, false) - if err != nil { - return fmt.Errorf("add dns state: %w", err) +// splitDomainsIntoBatches splits domains into batches respecting both element count and byte size limits. +func splitDomainsIntoBatches(domains []string) [][]string { + if len(domains) == 0 { + return nil } - log.Infof("added %d match domains to the state. Domain list: %s", len(strings.Split(domains, " ")), domains) + var batches [][]string + var current []string + currentBytes := 0 - s.createdKeys[key] = struct{}{} + for _, d := range domains { + domainLen := len(d) + newBytes := currentBytes + domainLen + if currentBytes > 0 { + newBytes++ // space separator + } + + if len(current) > 0 && (len(current) >= maxDomainsPerResolverEntry || newBytes > maxDomainBytesPerResolverEntry) { + batches = append(batches, current) + current = nil + currentBytes = 0 + } + + current = append(current, d) + if currentBytes > 0 { + currentBytes += 1 + domainLen + } else { + currentBytes = domainLen + } + } + + if len(current) > 0 { + batches = append(batches, current) + } + + return batches +} + +// removeKeysContaining removes all created keys that contain the given substring. +func (s *systemConfigurator) removeKeysContaining(suffix string) error { + var toRemove []string + for key := range s.createdKeys { + if strings.Contains(key, suffix) { + toRemove = append(toRemove, key) + } + } + var multiErr *multierror.Error + for _, key := range toRemove { + if err := s.removeKeyFromSystemConfig(key); err != nil { + multiErr = multierror.Append(multiErr, fmt.Errorf("couldn't remove key %s: %w", key, err)) + } + } + return nberrors.FormatErrorOrNil(multiErr) +} + +// addBatchedDomains splits domains into batches and creates indexed scutil keys for each batch. +func (s *systemConfigurator) addBatchedDomains(suffix string, domains []string, ip netip.Addr, port int, enableSearch bool) error { + batches := splitDomainsIntoBatches(domains) + + for i, batch := range batches { + key := fmt.Sprintf(netbirdDNSStateKeyIndexedFormat, suffix, i) + domainsStr := strings.Join(batch, " ") + + if err := s.addDNSState(key, domainsStr, ip, port, enableSearch); err != nil { + return fmt.Errorf("add dns state for batch %d: %w", i, err) + } + + s.createdKeys[key] = struct{}{} + } + + log.Infof("added %d %s domains across %d resolver entries", len(domains), suffix, len(batches)) return nil } @@ -345,7 +458,6 @@ func (s *systemConfigurator) flushDNSCache() error { if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("restart mDNSResponder: %w, output: %s", err, out) } - log.Info("flushed DNS cache") return nil } diff --git a/client/internal/dns/host_darwin_test.go b/client/internal/dns/host_darwin_test.go index c4efd17b0..94d020c39 100644 --- a/client/internal/dns/host_darwin_test.go +++ b/client/internal/dns/host_darwin_test.go @@ -3,7 +3,10 @@ package dns import ( + "bufio" + "bytes" "context" + "fmt" "net/netip" "os/exec" "path/filepath" @@ -49,17 +52,22 @@ func TestDarwinDNSUncleanShutdownCleanup(t *testing.T) { require.NoError(t, sm.PersistState(context.Background())) - searchKey := getKeyWithInput(netbirdDNSStateKeyFormat, searchSuffix) - matchKey := getKeyWithInput(netbirdDNSStateKeyFormat, matchSuffix) localKey := getKeyWithInput(netbirdDNSStateKeyFormat, localSuffix) + // Collect all created keys for cleanup verification + createdKeys := make([]string, 0, len(configurator.createdKeys)) + for key := range configurator.createdKeys { + createdKeys = append(createdKeys, key) + } + defer func() { - for _, key := range []string{searchKey, matchKey, localKey} { + for _, key := range createdKeys { _ = removeTestDNSKey(key) } + _ = removeTestDNSKey(localKey) }() - for _, key := range []string{searchKey, matchKey, localKey} { + for _, key := range createdKeys { exists, err := checkDNSKeyExists(key) require.NoError(t, err) if exists { @@ -83,13 +91,223 @@ func TestDarwinDNSUncleanShutdownCleanup(t *testing.T) { err = shutdownState.Cleanup() require.NoError(t, err) - for _, key := range []string{searchKey, matchKey, localKey} { + for _, key := range createdKeys { exists, err := checkDNSKeyExists(key) require.NoError(t, err) assert.False(t, exists, "Key %s should NOT exist after cleanup", key) } } +// generateShortDomains generates domains like a.com, b.com, ..., aa.com, ab.com, etc. +func generateShortDomains(count int) []string { + domains := make([]string, 0, count) + for i := range count { + label := "" + n := i + for { + label = string(rune('a'+n%26)) + label + n = n/26 - 1 + if n < 0 { + break + } + } + domains = append(domains, label+".com") + } + return domains +} + +// generateLongDomains generates domains like subdomain-000.department.organization-name.example.com +func generateLongDomains(count int) []string { + domains := make([]string, 0, count) + for i := range count { + domains = append(domains, fmt.Sprintf("subdomain-%03d.department.organization-name.example.com", i)) + } + return domains +} + +// readDomainsFromKey reads the SupplementalMatchDomains array back from scutil for a given key. +func readDomainsFromKey(t *testing.T, key string) []string { + t.Helper() + + cmd := exec.Command(scutilPath) + cmd.Stdin = strings.NewReader(fmt.Sprintf("open\nshow %s\nquit\n", key)) + out, err := cmd.Output() + require.NoError(t, err, "scutil show should succeed") + + var domains []string + inArray := false + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "SupplementalMatchDomains") && strings.Contains(line, "") { + inArray = true + continue + } + if inArray { + if line == "}" { + break + } + // lines look like: "0 : a.com" + parts := strings.SplitN(line, " : ", 2) + if len(parts) == 2 { + domains = append(domains, parts[1]) + } + } + } + require.NoError(t, scanner.Err()) + return domains +} + +func TestSplitDomainsIntoBatches(t *testing.T) { + tests := []struct { + name string + domains []string + expectedCount int + checkAllPresent bool + }{ + { + name: "empty", + domains: nil, + expectedCount: 0, + }, + { + name: "under_limit", + domains: generateShortDomains(10), + expectedCount: 1, + checkAllPresent: true, + }, + { + name: "at_element_limit", + domains: generateShortDomains(50), + expectedCount: 1, + checkAllPresent: true, + }, + { + name: "over_element_limit", + domains: generateShortDomains(51), + expectedCount: 2, + checkAllPresent: true, + }, + { + name: "triple_element_limit", + domains: generateShortDomains(150), + expectedCount: 3, + checkAllPresent: true, + }, + { + name: "long_domains_hit_byte_limit", + domains: generateLongDomains(50), + checkAllPresent: true, + }, + { + name: "500_short_domains", + domains: generateShortDomains(500), + expectedCount: 10, + checkAllPresent: true, + }, + { + name: "500_long_domains", + domains: generateLongDomains(500), + checkAllPresent: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + batches := splitDomainsIntoBatches(tc.domains) + + if tc.expectedCount > 0 { + assert.Len(t, batches, tc.expectedCount, "expected %d batches", tc.expectedCount) + } + + // Verify each batch respects limits + for i, batch := range batches { + assert.LessOrEqual(t, len(batch), maxDomainsPerResolverEntry, + "batch %d exceeds element limit", i) + + totalBytes := 0 + for j, d := range batch { + if j > 0 { + totalBytes++ + } + totalBytes += len(d) + } + assert.LessOrEqual(t, totalBytes, maxDomainBytesPerResolverEntry, + "batch %d exceeds byte limit (%d bytes)", i, totalBytes) + } + + if tc.checkAllPresent { + var all []string + for _, batch := range batches { + all = append(all, batch...) + } + assert.Equal(t, tc.domains, all, "all domains should be present in order") + } + }) + } +} + +// TestMatchDomainBatching writes increasing numbers of domains via the batching mechanism +// and verifies all domains are readable across multiple scutil keys. +func TestMatchDomainBatching(t *testing.T) { + if testing.Short() { + t.Skip("skipping scutil integration test in short mode") + } + + testCases := []struct { + name string + count int + generator func(int) []string + }{ + {"short_10", 10, generateShortDomains}, + {"short_50", 50, generateShortDomains}, + {"short_100", 100, generateShortDomains}, + {"short_200", 200, generateShortDomains}, + {"short_500", 500, generateShortDomains}, + {"long_10", 10, generateLongDomains}, + {"long_50", 50, generateLongDomains}, + {"long_100", 100, generateLongDomains}, + {"long_200", 200, generateLongDomains}, + {"long_500", 500, generateLongDomains}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + configurator := &systemConfigurator{ + createdKeys: make(map[string]struct{}), + } + + defer func() { + for key := range configurator.createdKeys { + _ = removeTestDNSKey(key) + } + }() + + domains := tc.generator(tc.count) + err := configurator.addBatchedDomains(matchSuffix, domains, netip.MustParseAddr("100.64.0.1"), 53, false) + require.NoError(t, err) + + batches := splitDomainsIntoBatches(domains) + t.Logf("wrote %d domains across %d batched keys", tc.count, len(batches)) + + // Read back all domains from all batched keys + var got []string + for i := range batches { + key := fmt.Sprintf(netbirdDNSStateKeyIndexedFormat, matchSuffix, i) + exists, err := checkDNSKeyExists(key) + require.NoError(t, err) + require.True(t, exists, "key %s should exist", key) + + got = append(got, readDomainsFromKey(t, key)...) + } + + t.Logf("read back %d/%d domains from %d keys", len(got), tc.count, len(batches)) + assert.Equal(t, tc.count, len(got), "all domains should be readable") + assert.Equal(t, domains, got, "domains should match in order") + }) + } +} + func checkDNSKeyExists(key string) (bool, error) { cmd := exec.Command(scutilPath) cmd.Stdin = strings.NewReader("show " + key + "\nquit\n") @@ -109,3 +327,169 @@ func removeTestDNSKey(key string) error { _, err := cmd.CombinedOutput() return err } + +func TestGetOriginalNameservers(t *testing.T) { + configurator := &systemConfigurator{ + createdKeys: make(map[string]struct{}), + origNameservers: []netip.Addr{ + netip.MustParseAddr("8.8.8.8"), + netip.MustParseAddr("1.1.1.1"), + }, + } + + servers := configurator.getOriginalNameservers() + assert.Len(t, servers, 2) + assert.Equal(t, netip.MustParseAddr("8.8.8.8"), servers[0]) + assert.Equal(t, netip.MustParseAddr("1.1.1.1"), servers[1]) +} + +func TestGetOriginalNameserversFromSystem(t *testing.T) { + configurator := &systemConfigurator{ + createdKeys: make(map[string]struct{}), + } + + _, err := configurator.getSystemDNSSettings() + require.NoError(t, err) + + servers := configurator.getOriginalNameservers() + + require.NotEmpty(t, servers, "expected at least one DNS server from system configuration") + + for _, server := range servers { + assert.True(t, server.IsValid(), "server address should be valid") + assert.False(t, server.IsUnspecified(), "server address should not be unspecified") + } + + t.Logf("found %d original nameservers: %v", len(servers), servers) +} + +func setupTestConfigurator(t *testing.T) (*systemConfigurator, *statemanager.Manager, func()) { + t.Helper() + + tmpDir := t.TempDir() + stateFile := filepath.Join(tmpDir, "state.json") + sm := statemanager.New(stateFile) + sm.RegisterState(&ShutdownState{}) + sm.Start() + + configurator := &systemConfigurator{ + createdKeys: make(map[string]struct{}), + } + + cleanup := func() { + _ = sm.Stop(context.Background()) + for key := range configurator.createdKeys { + _ = removeTestDNSKey(key) + } + // Also clean up old-format keys and local key in case they exist + _ = removeTestDNSKey(getKeyWithInput(netbirdDNSStateKeyFormat, searchSuffix)) + _ = removeTestDNSKey(getKeyWithInput(netbirdDNSStateKeyFormat, matchSuffix)) + _ = removeTestDNSKey(getKeyWithInput(netbirdDNSStateKeyFormat, localSuffix)) + } + + return configurator, sm, cleanup +} + +func TestOriginalNameserversNoTransition(t *testing.T) { + netbirdIP := netip.MustParseAddr("100.64.0.1") + + testCases := []struct { + name string + routeAll bool + }{ + {"routeall_false", false}, + {"routeall_true", true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + configurator, sm, cleanup := setupTestConfigurator(t) + defer cleanup() + + _, err := configurator.getSystemDNSSettings() + require.NoError(t, err) + initialServers := configurator.getOriginalNameservers() + t.Logf("Initial servers: %v", initialServers) + require.NotEmpty(t, initialServers) + + for _, srv := range initialServers { + require.NotEqual(t, netbirdIP, srv, "initial servers should not contain NetBird IP") + } + + config := HostDNSConfig{ + ServerIP: netbirdIP, + ServerPort: 53, + RouteAll: tc.routeAll, + Domains: []DomainConfig{{Domain: "example.com", MatchOnly: true}}, + } + + for i := 1; i <= 2; i++ { + err = configurator.applyDNSConfig(config, sm) + require.NoError(t, err) + + servers := configurator.getOriginalNameservers() + t.Logf("After apply %d (RouteAll=%v): %v", i, tc.routeAll, servers) + assert.Equal(t, initialServers, servers) + } + }) + } +} + +func TestOriginalNameserversRouteAllTransition(t *testing.T) { + netbirdIP := netip.MustParseAddr("100.64.0.1") + + testCases := []struct { + name string + initialRoute bool + }{ + {"start_with_routeall_false", false}, + {"start_with_routeall_true", true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + configurator, sm, cleanup := setupTestConfigurator(t) + defer cleanup() + + _, err := configurator.getSystemDNSSettings() + require.NoError(t, err) + initialServers := configurator.getOriginalNameservers() + t.Logf("Initial servers: %v", initialServers) + require.NotEmpty(t, initialServers) + + config := HostDNSConfig{ + ServerIP: netbirdIP, + ServerPort: 53, + RouteAll: tc.initialRoute, + Domains: []DomainConfig{{Domain: "example.com", MatchOnly: true}}, + } + + // First apply + err = configurator.applyDNSConfig(config, sm) + require.NoError(t, err) + servers := configurator.getOriginalNameservers() + t.Logf("After first apply (RouteAll=%v): %v", tc.initialRoute, servers) + assert.Equal(t, initialServers, servers) + + // Toggle RouteAll + config.RouteAll = !tc.initialRoute + err = configurator.applyDNSConfig(config, sm) + require.NoError(t, err) + servers = configurator.getOriginalNameservers() + t.Logf("After toggle (RouteAll=%v): %v", config.RouteAll, servers) + assert.Equal(t, initialServers, servers) + + // Toggle back + config.RouteAll = tc.initialRoute + err = configurator.applyDNSConfig(config, sm) + require.NoError(t, err) + servers = configurator.getOriginalNameservers() + t.Logf("After toggle back (RouteAll=%v): %v", config.RouteAll, servers) + assert.Equal(t, initialServers, servers) + + for _, srv := range servers { + assert.NotEqual(t, netbirdIP, srv, "servers should not contain NetBird IP") + } + }) + } +} diff --git a/client/internal/dns/host_windows.go b/client/internal/dns/host_windows.go index 01b7edc48..4a8cf8cec 100644 --- a/client/internal/dns/host_windows.go +++ b/client/internal/dns/host_windows.go @@ -42,6 +42,8 @@ const ( dnsPolicyConfigConfigOptionsKey = "ConfigOptions" dnsPolicyConfigConfigOptionsValue = 0x8 + nrptMaxDomainsPerRule = 50 + interfaceConfigPath = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces` interfaceConfigNameServerKey = "NameServer" interfaceConfigSearchListKey = "SearchList" @@ -198,10 +200,11 @@ func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager 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 { return fmt.Errorf("add dns match policy: %w", err) } - r.nrptEntryCount = count } else { r.nrptEntryCount = 0 } @@ -239,23 +242,33 @@ func (r *registryConfigurator) addDNSSetupForAll(ip netip.Addr) error { func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr) (int, 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 - for i, domain := range domains { - localPath := fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i) - gpoPath := fmt.Sprintf("%s-%d", gpoDnsPolicyConfigMatchPath, i) - singleDomain := []string{domain} + // We need to batch domains into chunks and create one NRPT rule per batch. + ruleIndex := 0 + for i := 0; i < len(domains); i += nrptMaxDomainsPerRule { + end := i + nrptMaxDomainsPerRule + if end > len(domains) { + end = len(domains) + } + batchDomains := domains[i:end] - if err := r.configureDNSPolicy(localPath, singleDomain, ip); err != nil { - return i, fmt.Errorf("configure DNS Local policy for domain %s: %w", domain, err) + localPath := fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, ruleIndex) + 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) } + // Increment immediately so the caller's cleanup path knows about this rule + ruleIndex++ + if r.gpo { - if err := r.configureDNSPolicy(gpoPath, singleDomain, ip); err != nil { - return i, fmt.Errorf("configure gpo DNS policy: %w", err) + if err := r.configureDNSPolicy(gpoPath, batchDomains, ip); err != nil { + return ruleIndex, fmt.Errorf("configure gpo DNS policy for rule %d: %w", ruleIndex-1, err) } } - log.Debugf("added NRPT entry for domain: %s", domain) + log.Debugf("added NRPT rule %d with %d domains", ruleIndex-1, len(batchDomains)) } if r.gpo { @@ -264,8 +277,8 @@ func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr } } - log.Infof("added %d separate NRPT entries. Domain list: %s", len(domains), domains) - return len(domains), nil + log.Infof("added %d NRPT rules for %d domains", ruleIndex, len(domains)) + return ruleIndex, nil } func (r *registryConfigurator) configureDNSPolicy(policyPath string, domains []string, ip netip.Addr) error { diff --git a/client/internal/dns/host_windows_test.go b/client/internal/dns/host_windows_test.go index 19496bf5a..3cd2b1bd5 100644 --- a/client/internal/dns/host_windows_test.go +++ b/client/internal/dns/host_windows_test.go @@ -12,6 +12,7 @@ import ( // TestNRPTEntriesCleanupOnConfigChange tests that old NRPT entries are properly cleaned up // when the number of match domains decreases between configuration changes. +// With batching enabled (50 domains per rule), we need enough domains to create multiple rules. func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) { if testing.Short() { t.Skip("skipping registry integration test in short mode") @@ -37,51 +38,60 @@ func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) { gpo: false, } - config5 := HostDNSConfig{ - ServerIP: testIP, - Domains: []DomainConfig{ - {Domain: "domain1.com", MatchOnly: true}, - {Domain: "domain2.com", MatchOnly: true}, - {Domain: "domain3.com", MatchOnly: true}, - {Domain: "domain4.com", MatchOnly: true}, - {Domain: "domain5.com", MatchOnly: true}, - }, + // Create 125 domains which will result in 3 NRPT rules (50+50+25) + domains125 := make([]DomainConfig, 125) + for i := 0; i < 125; i++ { + domains125[i] = DomainConfig{ + Domain: fmt.Sprintf("domain%d.com", i+1), + MatchOnly: true, + } } - err = cfg.applyDNSConfig(config5, nil) + config125 := HostDNSConfig{ + ServerIP: testIP, + Domains: domains125, + } + + err = cfg.applyDNSConfig(config125, nil) require.NoError(t, err) - // Verify all 5 entries exist - for i := 0; i < 5; i++ { + // Verify 3 NRPT rules exist + assert.Equal(t, 3, cfg.nrptEntryCount, "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) - assert.True(t, exists, "Entry %d should exist after first config", i) + assert.True(t, exists, "NRPT rule %d should exist after first config", i) } - config2 := HostDNSConfig{ + // Reduce to 75 domains which will result in 2 NRPT rules (50+25) + domains75 := make([]DomainConfig, 75) + for i := 0; i < 75; i++ { + domains75[i] = DomainConfig{ + Domain: fmt.Sprintf("domain%d.com", i+1), + MatchOnly: true, + } + } + + config75 := HostDNSConfig{ ServerIP: testIP, - Domains: []DomainConfig{ - {Domain: "domain1.com", MatchOnly: true}, - {Domain: "domain2.com", MatchOnly: true}, - }, + Domains: domains75, } - err = cfg.applyDNSConfig(config2, nil) + err = cfg.applyDNSConfig(config75, nil) require.NoError(t, err) - // Verify first 2 entries exist + // Verify first 2 NRPT rules exist + assert.Equal(t, 2, cfg.nrptEntryCount, "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) - assert.True(t, exists, "Entry %d should exist after second config", i) + assert.True(t, exists, "NRPT rule %d should exist after second config", i) } - // Verify entries 2-4 are cleaned up - for i := 2; i < 5; i++ { - exists, err := registryKeyExists(fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i)) - require.NoError(t, err) - assert.False(t, exists, "Entry %d should NOT exist after reducing to 2 domains", i) - } + // Verify rule 2 is cleaned up + exists, err := registryKeyExists(fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, 2)) + require.NoError(t, err) + assert.False(t, exists, "NRPT rule 2 should NOT exist after reducing to 75 domains") } func registryKeyExists(path string) (bool, error) { @@ -97,6 +107,106 @@ func registryKeyExists(path string) (bool, error) { } func cleanupRegistryKeys(*testing.T) { - cfg := ®istryConfigurator{nrptEntryCount: 10} + // Clean up more entries to account for batching tests with many domains + cfg := ®istryConfigurator{nrptEntryCount: 20} _ = cfg.removeDNSMatchPolicies() } + +// TestNRPTDomainBatching verifies that domains are correctly batched into NRPT rules. +func TestNRPTDomainBatching(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") + + // 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 + testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE) + require.NoError(t, err, "Should create test interface registry key") + testKey.Close() + defer func() { + _ = registry.DeleteKey(registry.LOCAL_MACHINE, interfacePath) + }() + + cfg := ®istryConfigurator{ + guid: testGUID, + gpo: false, + } + + testCases := []struct { + name string + domainCount int + expectedRuleCount int + }{ + { + name: "Less than 50 domains (single rule)", + domainCount: 30, + expectedRuleCount: 1, + }, + { + name: "Exactly 50 domains (single rule)", + domainCount: 50, + expectedRuleCount: 1, + }, + { + name: "51 domains (two rules)", + domainCount: 51, + expectedRuleCount: 2, + }, + { + name: "100 domains (two rules)", + domainCount: 100, + expectedRuleCount: 2, + }, + { + name: "125 domains (three rules: 50+50+25)", + domainCount: 125, + expectedRuleCount: 3, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Clean up before each subtest + cleanupRegistryKeys(t) + + // Generate domains + domains := make([]DomainConfig, tc.domainCount) + for i := 0; i < tc.domainCount; i++ { + domains[i] = DomainConfig{ + Domain: fmt.Sprintf("domain%d.com", i+1), + MatchOnly: true, + } + } + + config := HostDNSConfig{ + ServerIP: testIP, + Domains: domains, + } + + err := cfg.applyDNSConfig(config, nil) + require.NoError(t, err) + + // Verify that exactly expectedRuleCount rules were created + assert.Equal(t, tc.expectedRuleCount, cfg.nrptEntryCount, + "Should create %d NRPT rules for %d domains", tc.expectedRuleCount, tc.domainCount) + + // Verify all expected rules exist + for i := 0; i < tc.expectedRuleCount; i++ { + exists, err := registryKeyExists(fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i)) + require.NoError(t, err) + assert.True(t, exists, "NRPT rule %d should exist", i) + } + + // Verify no extra rules were created + exists, err := registryKeyExists(fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, tc.expectedRuleCount)) + require.NoError(t, err) + assert.False(t, exists, "No NRPT rule should exist at index %d", tc.expectedRuleCount) + }) + } +} diff --git a/client/internal/dns/local/local.go b/client/internal/dns/local/local.go index b374bcc6a..a67a23945 100644 --- a/client/internal/dns/local/local.go +++ b/client/internal/dns/local/local.go @@ -77,7 +77,7 @@ func (d *Resolver) ID() types.HandlerID { return "local-resolver" } -func (d *Resolver) ProbeAvailability() {} +func (d *Resolver) ProbeAvailability(context.Context) {} // ServeDNS handles a DNS request func (d *Resolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { diff --git a/client/internal/dns/mgmt/mgmt.go b/client/internal/dns/mgmt/mgmt.go index d01be0c2c..314af51d9 100644 --- a/client/internal/dns/mgmt/mgmt.go +++ b/client/internal/dns/mgmt/mgmt.go @@ -376,9 +376,9 @@ func (m *Resolver) extractDomainsFromServerDomains(serverDomains dnsconfig.Serve } } - if serverDomains.Flow != "" { - domains = append(domains, serverDomains.Flow) - } + // Flow receiver domain is intentionally excluded from caching. + // Cloud providers may rotate the IP behind this domain; a stale cached record + // causes TLS certificate verification failures on reconnect. for _, stun := range serverDomains.Stuns { if stun != "" { diff --git a/client/internal/dns/mgmt/mgmt_test.go b/client/internal/dns/mgmt/mgmt_test.go index 99d289871..9e8a746f3 100644 --- a/client/internal/dns/mgmt/mgmt_test.go +++ b/client/internal/dns/mgmt/mgmt_test.go @@ -391,7 +391,8 @@ func TestResolver_PartialUpdateAddsNewTypePreservesExisting(t *testing.T) { } assert.Len(t, resolver.GetCachedDomains(), 3) - // Update with partial ServerDomains (only flow domain - new type, should preserve all existing) + // Update with partial ServerDomains (only flow domain - flow is intentionally excluded from + // caching to prevent TLS failures from stale records, so all existing domains are preserved) partialDomains := dnsconfig.ServerDomains{ Flow: "github.com", } @@ -400,10 +401,10 @@ func TestResolver_PartialUpdateAddsNewTypePreservesExisting(t *testing.T) { t.Skipf("Skipping test due to DNS resolution failure: %v", err) } - assert.Len(t, removedDomains, 0, "Should not remove any domains when adding new type") + assert.Len(t, removedDomains, 0, "Should not remove any domains when only flow domain is provided") finalDomains := resolver.GetCachedDomains() - assert.Len(t, finalDomains, 4, "Should have all original domains plus new flow domain") + assert.Len(t, finalDomains, 3, "Flow domain is not cached; all original domains should be preserved") domainStrings := make([]string, len(finalDomains)) for i, d := range finalDomains { @@ -412,5 +413,5 @@ func TestResolver_PartialUpdateAddsNewTypePreservesExisting(t *testing.T) { assert.Contains(t, domainStrings, "example.org") assert.Contains(t, domainStrings, "google.com") assert.Contains(t, domainStrings, "cloudflare.com") - assert.Contains(t, domainStrings, "github.com") + assert.NotContains(t, domainStrings, "github.com") } diff --git a/client/internal/dns/mock_server.go b/client/internal/dns/mock_server.go index 0f89b9016..fe160e20a 100644 --- a/client/internal/dns/mock_server.go +++ b/client/internal/dns/mock_server.go @@ -84,3 +84,18 @@ func (m *MockServer) UpdateServerConfig(domains dnsconfig.ServerDomains) error { func (m *MockServer) PopulateManagementDomain(mgmtURL *url.URL) error { return nil } + +// BeginBatch mock implementation of BeginBatch from Server interface +func (m *MockServer) BeginBatch() { + // Mock implementation - no-op +} + +// EndBatch mock implementation of EndBatch from Server interface +func (m *MockServer) EndBatch() { + // Mock implementation - no-op +} + +// CancelBatch mock implementation of CancelBatch from Server interface +func (m *MockServer) CancelBatch() { + // Mock implementation - no-op +} diff --git a/client/internal/dns/server.go b/client/internal/dns/server.go index 1ce7bf1c6..6ca4f7957 100644 --- a/client/internal/dns/server.go +++ b/client/internal/dns/server.go @@ -6,7 +6,9 @@ import ( "fmt" "net/netip" "net/url" + "os" "runtime" + "strconv" "strings" "sync" @@ -27,6 +29,8 @@ import ( "github.com/netbirdio/netbird/shared/management/domain" ) +const envSkipDNSProbe = "NB_SKIP_DNS_PROBE" + // ReadyListener is a notification mechanism what indicate the server is ready to handle host dns address changes type ReadyListener interface { OnReady() @@ -41,6 +45,9 @@ type IosDnsManager interface { type Server interface { RegisterHandler(domains domain.List, handler dns.Handler, priority int) DeregisterHandler(domains domain.List, priority int) + BeginBatch() + EndBatch() + CancelBatch() Initialize() error Stop() DnsIP() netip.Addr @@ -83,6 +90,7 @@ type DefaultServer struct { currentConfigHash uint64 handlerChain *HandlerChain extraDomains map[domain.Domain]int + batchMode bool mgmtCacheResolver *mgmt.Resolver @@ -96,12 +104,16 @@ type DefaultServer struct { statusRecorder *peer.Status stateManager *statemanager.Manager + + probeMu sync.Mutex + probeCancel context.CancelFunc + probeWg sync.WaitGroup } type handlerWithStop interface { dns.Handler Stop() - ProbeAvailability() + ProbeAvailability(context.Context) ID() types.HandlerID } @@ -230,7 +242,9 @@ func (s *DefaultServer) RegisterHandler(domains domain.List, handler dns.Handler // convert to zone with simple ref counter s.extraDomains[toZone(domain)]++ } - s.applyHostConfig() + if !s.batchMode { + s.applyHostConfig() + } } func (s *DefaultServer) registerHandler(domains []string, handler dns.Handler, priority int) { @@ -259,9 +273,41 @@ func (s *DefaultServer) DeregisterHandler(domains domain.List, priority int) { delete(s.extraDomains, zone) } } + if !s.batchMode { + s.applyHostConfig() + } +} + +// BeginBatch starts batch mode for DNS handler registration/deregistration. +// In batch mode, applyHostConfig() is not called after each handler operation, +// allowing multiple handlers to be registered/deregistered efficiently. +// Must be followed by EndBatch() to apply the accumulated changes. +func (s *DefaultServer) BeginBatch() { + s.mux.Lock() + defer s.mux.Unlock() + log.Debugf("DNS batch mode enabled") + s.batchMode = true +} + +// EndBatch ends batch mode and applies all accumulated DNS configuration changes. +func (s *DefaultServer) EndBatch() { + s.mux.Lock() + defer s.mux.Unlock() + log.Debugf("DNS batch mode disabled, applying accumulated changes") + s.batchMode = false s.applyHostConfig() } +// CancelBatch cancels batch mode without applying accumulated changes. +// This is useful when operations fail partway through and you want to +// discard partial state rather than applying it. +func (s *DefaultServer) CancelBatch() { + s.mux.Lock() + defer s.mux.Unlock() + log.Debugf("DNS batch mode cancelled, discarding accumulated changes") + s.batchMode = false +} + func (s *DefaultServer) deregisterHandler(domains []string, priority int) { log.Debugf("deregistering handler with priority %d for %v", priority, domains) @@ -320,7 +366,13 @@ func (s *DefaultServer) DnsIP() netip.Addr { // Stop stops the server func (s *DefaultServer) Stop() { + s.probeMu.Lock() + if s.probeCancel != nil { + s.probeCancel() + } s.ctxCancel() + s.probeMu.Unlock() + s.probeWg.Wait() s.shutdownWg.Wait() s.mux.Lock() @@ -437,17 +489,66 @@ func (s *DefaultServer) SearchDomains() []string { } // ProbeAvailability tests each upstream group's servers for availability -// and deactivates the group if no server responds +// and deactivates the group if no server responds. +// If a previous probe is still running, it will be cancelled before starting a new one. func (s *DefaultServer) ProbeAvailability() { - var wg sync.WaitGroup - for _, mux := range s.dnsMuxMap { - wg.Add(1) - go func(mux handlerWithStop) { - defer wg.Done() - mux.ProbeAvailability() - }(mux.handler) + if val := os.Getenv(envSkipDNSProbe); val != "" { + skipProbe, err := strconv.ParseBool(val) + if err != nil { + log.Warnf("failed to parse %s: %v", envSkipDNSProbe, err) + } + if skipProbe { + log.Infof("skipping DNS probe due to %s", envSkipDNSProbe) + return + } } + + s.probeMu.Lock() + + // don't start probes on a stopped server + if s.ctx.Err() != nil { + s.probeMu.Unlock() + return + } + + // cancel any running probe + if s.probeCancel != nil { + s.probeCancel() + s.probeCancel = nil + } + + // wait for the previous probe goroutines to finish while holding + // the mutex so no other caller can start a new probe concurrently + s.probeWg.Wait() + + // start a new probe + probeCtx, probeCancel := context.WithCancel(s.ctx) + s.probeCancel = probeCancel + + s.probeWg.Add(1) + defer s.probeWg.Done() + + // Snapshot handlers under s.mux to avoid racing with updateMux/dnsMuxMap writers. + s.mux.Lock() + handlers := make([]handlerWithStop, 0, len(s.dnsMuxMap)) + for _, mux := range s.dnsMuxMap { + handlers = append(handlers, mux.handler) + } + s.mux.Unlock() + + var wg sync.WaitGroup + for _, handler := range handlers { + wg.Add(1) + go func(h handlerWithStop) { + defer wg.Done() + h.ProbeAvailability(probeCtx) + }(handler) + } + + s.probeMu.Unlock() + wg.Wait() + probeCancel() } func (s *DefaultServer) UpdateServerConfig(domains dnsconfig.ServerDomains) error { @@ -508,6 +609,7 @@ func (s *DefaultServer) applyConfiguration(update nbdns.Config) error { s.currentConfig.RouteAll = false } + // Always apply host config for management updates, regardless of batch mode s.applyHostConfig() s.shutdownWg.Add(1) @@ -615,7 +717,7 @@ func (s *DefaultServer) applyHostConfig() { s.registerFallback(config) } -// registerFallback registers original nameservers as low-priority fallback handlers +// registerFallback registers original nameservers as low-priority fallback handlers. func (s *DefaultServer) registerFallback(config HostDNSConfig) { hostMgrWithNS, ok := s.hostManager.(hostManagerWithOriginalNS) if !ok { @@ -624,6 +726,7 @@ func (s *DefaultServer) registerFallback(config HostDNSConfig) { originalNameservers := hostMgrWithNS.getOriginalNameservers() if len(originalNameservers) == 0 { + s.deregisterHandler([]string{nbdns.RootZone}, PriorityFallback) return } @@ -871,6 +974,7 @@ func (s *DefaultServer) upstreamCallbacks( } } + // Always apply host config when nameserver goes down, regardless of batch mode s.applyHostConfig() go func() { @@ -906,6 +1010,7 @@ func (s *DefaultServer) upstreamCallbacks( s.registerHandler([]string{nbdns.RootZone}, handler, priority) } + // Always apply host config when nameserver reactivates, regardless of batch mode s.applyHostConfig() s.updateNSState(nsGroup, nil, true) diff --git a/client/internal/dns/server_export_test.go b/client/internal/dns/server_export_test.go index 1fa343b52..25d08d698 100644 --- a/client/internal/dns/server_export_test.go +++ b/client/internal/dns/server_export_test.go @@ -18,7 +18,12 @@ func TestGetServerDns(t *testing.T) { t.Errorf("invalid dns server instance: %s", err) } - if srvB != srv { + mockSrvB, ok := srvB.(*MockServer) + if !ok { + t.Errorf("returned server is not a MockServer") + } + + if mockSrvB != srv { t.Errorf("mismatch dns instances") } } diff --git a/client/internal/dns/server_test.go b/client/internal/dns/server_test.go index 3606d48b9..d3b0c250d 100644 --- a/client/internal/dns/server_test.go +++ b/client/internal/dns/server_test.go @@ -1065,7 +1065,7 @@ type mockHandler struct { func (m *mockHandler) ServeDNS(dns.ResponseWriter, *dns.Msg) {} func (m *mockHandler) Stop() {} -func (m *mockHandler) ProbeAvailability() {} +func (m *mockHandler) ProbeAvailability(context.Context) {} func (m *mockHandler) ID() types.HandlerID { return types.HandlerID(m.Id) } type mockService struct{} diff --git a/client/internal/dns/test/mock.go b/client/internal/dns/test/mock.go index 1db452805..8d16689bf 100644 --- a/client/internal/dns/test/mock.go +++ b/client/internal/dns/test/mock.go @@ -8,15 +8,21 @@ import ( type MockResponseWriter struct { WriteMsgFunc func(m *dns.Msg) error + lastResponse *dns.Msg } func (rw *MockResponseWriter) WriteMsg(m *dns.Msg) error { + rw.lastResponse = m if rw.WriteMsgFunc != nil { return rw.WriteMsgFunc(m) } return nil } +func (rw *MockResponseWriter) GetLastResponse() *dns.Msg { + return rw.lastResponse +} + func (rw *MockResponseWriter) LocalAddr() net.Addr { return nil } func (rw *MockResponseWriter) RemoteAddr() net.Addr { return nil } func (rw *MockResponseWriter) Write([]byte) (int, error) { return 0, nil } diff --git a/client/internal/dns/upstream.go b/client/internal/dns/upstream.go index 0fbd32771..18128a942 100644 --- a/client/internal/dns/upstream.go +++ b/client/internal/dns/upstream.go @@ -65,6 +65,7 @@ type upstreamResolverBase struct { mutex sync.Mutex reactivatePeriod time.Duration upstreamTimeout time.Duration + wg sync.WaitGroup deactivate func(error) reactivate func() @@ -115,6 +116,11 @@ func (u *upstreamResolverBase) MatchSubdomains() bool { func (u *upstreamResolverBase) Stop() { log.Debugf("stopping serving DNS for upstreams %s", u.upstreamServers) u.cancel() + + u.mutex.Lock() + u.wg.Wait() + u.mutex.Unlock() + } // ServeDNS handles a DNS request @@ -260,16 +266,10 @@ func formatFailures(failures []upstreamFailure) string { // ProbeAvailability tests all upstream servers simultaneously and // disables the resolver if none work -func (u *upstreamResolverBase) ProbeAvailability() { +func (u *upstreamResolverBase) ProbeAvailability(ctx context.Context) { u.mutex.Lock() defer u.mutex.Unlock() - select { - case <-u.ctx.Done(): - return - default: - } - // avoid probe if upstreams could resolve at least one query if u.successCount.Load() > 0 { return @@ -279,31 +279,39 @@ func (u *upstreamResolverBase) ProbeAvailability() { var mu sync.Mutex var wg sync.WaitGroup - var errors *multierror.Error + var errs *multierror.Error for _, upstream := range u.upstreamServers { - upstream := upstream - wg.Add(1) - go func() { + go func(upstream netip.AddrPort) { defer wg.Done() - err := u.testNameserver(upstream, 500*time.Millisecond) + err := u.testNameserver(u.ctx, ctx, upstream, 500*time.Millisecond) if err != nil { - errors = multierror.Append(errors, err) + mu.Lock() + errs = multierror.Append(errs, err) + mu.Unlock() log.Warnf("probing upstream nameserver %s: %s", upstream, err) return } mu.Lock() - defer mu.Unlock() success = true - }() + mu.Unlock() + }(upstream) } wg.Wait() + select { + case <-ctx.Done(): + return + case <-u.ctx.Done(): + return + default: + } + // didn't find a working upstream server, let's disable and try later if !success { - u.disable(errors.ErrorOrNil()) + u.disable(errs.ErrorOrNil()) if u.statusRecorder == nil { return @@ -339,7 +347,7 @@ func (u *upstreamResolverBase) waitUntilResponse() { } for _, upstream := range u.upstreamServers { - if err := u.testNameserver(upstream, probeTimeout); err != nil { + if err := u.testNameserver(u.ctx, nil, upstream, probeTimeout); err != nil { log.Tracef("upstream check for %s: %s", upstream, err) } else { // at least one upstream server is available, stop probing @@ -351,16 +359,22 @@ func (u *upstreamResolverBase) waitUntilResponse() { return fmt.Errorf("upstream check call error") } - err := backoff.Retry(operation, exponentialBackOff) + err := backoff.Retry(operation, backoff.WithContext(exponentialBackOff, u.ctx)) if err != nil { - log.Warn(err) + if errors.Is(err, context.Canceled) { + log.Debugf("upstream retry loop exited for upstreams %s", u.upstreamServersString()) + } else { + log.Warnf("upstream retry loop exited for upstreams %s: %v", u.upstreamServersString(), err) + } return } log.Infof("upstreams %s are responsive again. Adding them back to system", u.upstreamServersString()) u.successCount.Add(1) u.reactivate() + u.mutex.Lock() u.disabled = false + u.mutex.Unlock() } // isTimeout returns true if the given error is a network timeout error. @@ -383,7 +397,11 @@ func (u *upstreamResolverBase) disable(err error) { u.successCount.Store(0) u.deactivate(err) u.disabled = true - go u.waitUntilResponse() + u.wg.Add(1) + go func() { + defer u.wg.Done() + u.waitUntilResponse() + }() } func (u *upstreamResolverBase) upstreamServersString() string { @@ -394,13 +412,18 @@ func (u *upstreamResolverBase) upstreamServersString() string { return strings.Join(servers, ", ") } -func (u *upstreamResolverBase) testNameserver(server netip.AddrPort, timeout time.Duration) error { - ctx, cancel := context.WithTimeout(u.ctx, timeout) +func (u *upstreamResolverBase) testNameserver(baseCtx context.Context, externalCtx context.Context, server netip.AddrPort, timeout time.Duration) error { + mergedCtx, cancel := context.WithTimeout(baseCtx, timeout) defer cancel() + if externalCtx != nil { + stop2 := context.AfterFunc(externalCtx, cancel) + defer stop2() + } + r := new(dns.Msg).SetQuestion(testRecord, dns.TypeSOA) - _, _, err := u.upstreamClient.exchange(ctx, server.String(), r) + _, _, err := u.upstreamClient.exchange(mergedCtx, server.String(), r) return err } diff --git a/client/internal/dns/upstream_test.go b/client/internal/dns/upstream_test.go index 8b06e4475..ab164c30b 100644 --- a/client/internal/dns/upstream_test.go +++ b/client/internal/dns/upstream_test.go @@ -188,7 +188,7 @@ func TestUpstreamResolver_DeactivationReactivation(t *testing.T) { reactivated = true } - resolver.ProbeAvailability() + resolver.ProbeAvailability(context.TODO()) if !failed { t.Errorf("expected that resolving was deactivated") diff --git a/client/internal/dnsfwd/forwarder.go b/client/internal/dnsfwd/forwarder.go index 1230a4e46..5c7cb31fc 100644 --- a/client/internal/dnsfwd/forwarder.go +++ b/client/internal/dnsfwd/forwarder.go @@ -190,50 +190,75 @@ func (f *DNSForwarder) Close(ctx context.Context) error { return nberrors.FormatErrorOrNil(result) } -func (f *DNSForwarder) handleDNSQuery(logger *log.Entry, w dns.ResponseWriter, query *dns.Msg) *dns.Msg { +func (f *DNSForwarder) handleDNSQuery(logger *log.Entry, w dns.ResponseWriter, query *dns.Msg, startTime time.Time) { if len(query.Question) == 0 { - return nil + return } question := query.Question[0] - logger.Tracef("received DNS request for DNS forwarder: domain=%s type=%s class=%s", - question.Name, dns.TypeToString[question.Qtype], dns.ClassToString[question.Qclass]) + qname := strings.ToLower(question.Name) - domain := strings.ToLower(question.Name) + logger.Tracef("question: domain=%s type=%s class=%s", + qname, dns.TypeToString[question.Qtype], dns.ClassToString[question.Qclass]) resp := query.SetReply(query) network := resutil.NetworkForQtype(question.Qtype) if network == "" { resp.Rcode = dns.RcodeNotImplemented - if err := w.WriteMsg(resp); err != nil { - logger.Errorf("failed to write DNS response: %v", err) - } - return nil + f.writeResponse(logger, w, resp, qname, startTime) + return } - mostSpecificResId, matchingEntries := f.getMatchingEntries(strings.TrimSuffix(domain, ".")) - // query doesn't match any configured domain + mostSpecificResId, matchingEntries := f.getMatchingEntries(strings.TrimSuffix(qname, ".")) if mostSpecificResId == "" { resp.Rcode = dns.RcodeRefused - if err := w.WriteMsg(resp); err != nil { - logger.Errorf("failed to write DNS response: %v", err) - } - return nil + f.writeResponse(logger, w, resp, qname, startTime) + return } ctx, cancel := context.WithTimeout(context.Background(), upstreamTimeout) defer cancel() - result := resutil.LookupIP(ctx, f.resolver, network, domain, question.Qtype) + result := resutil.LookupIP(ctx, f.resolver, network, qname, question.Qtype) if result.Err != nil { - f.handleDNSError(ctx, logger, w, question, resp, domain, result) - return nil + f.handleDNSError(ctx, logger, w, question, resp, qname, result, startTime) + return } f.updateInternalState(result.IPs, mostSpecificResId, matchingEntries) - resp.Answer = append(resp.Answer, resutil.IPsToRRs(domain, result.IPs, f.ttl)...) - f.cache.set(domain, question.Qtype, result.IPs) + resp.Answer = append(resp.Answer, resutil.IPsToRRs(qname, result.IPs, f.ttl)...) + f.cache.set(qname, question.Qtype, result.IPs) - return resp + f.writeResponse(logger, w, resp, qname, startTime) +} + +func (f *DNSForwarder) writeResponse(logger *log.Entry, w dns.ResponseWriter, resp *dns.Msg, qname string, startTime time.Time) { + if err := w.WriteMsg(resp); err != nil { + logger.Errorf("failed to write DNS response: %v", err) + return + } + + logger.Tracef("response: domain=%s rcode=%s answers=%s took=%s", + qname, dns.RcodeToString[resp.Rcode], resutil.FormatAnswers(resp.Answer), time.Since(startTime)) +} + +// udpResponseWriter wraps a dns.ResponseWriter to handle UDP-specific truncation. +type udpResponseWriter struct { + dns.ResponseWriter + query *dns.Msg +} + +func (u *udpResponseWriter) WriteMsg(resp *dns.Msg) error { + opt := u.query.IsEdns0() + maxSize := dns.MinMsgSize + if opt != nil { + maxSize = int(opt.UDPSize()) + } + + if resp.Len() > maxSize { + resp.Truncate(maxSize) + } + + return u.ResponseWriter.WriteMsg(resp) } func (f *DNSForwarder) handleDNSQueryUDP(w dns.ResponseWriter, query *dns.Msg) { @@ -243,30 +268,7 @@ func (f *DNSForwarder) handleDNSQueryUDP(w dns.ResponseWriter, query *dns.Msg) { "dns_id": fmt.Sprintf("%04x", query.Id), }) - resp := f.handleDNSQuery(logger, w, query) - if resp == nil { - return - } - - opt := query.IsEdns0() - maxSize := dns.MinMsgSize - if opt != nil { - // client advertised a larger EDNS0 buffer - maxSize = int(opt.UDPSize()) - } - - // if our response is too big, truncate and set the TC bit - if resp.Len() > maxSize { - resp.Truncate(maxSize) - } - - if err := w.WriteMsg(resp); err != nil { - logger.Errorf("failed to write DNS response: %v", err) - return - } - - logger.Tracef("response: domain=%s rcode=%s answers=%s took=%s", - query.Question[0].Name, dns.RcodeToString[resp.Rcode], resutil.FormatAnswers(resp.Answer), time.Since(startTime)) + f.handleDNSQuery(logger, &udpResponseWriter{ResponseWriter: w, query: query}, query, startTime) } func (f *DNSForwarder) handleDNSQueryTCP(w dns.ResponseWriter, query *dns.Msg) { @@ -276,18 +278,7 @@ func (f *DNSForwarder) handleDNSQueryTCP(w dns.ResponseWriter, query *dns.Msg) { "dns_id": fmt.Sprintf("%04x", query.Id), }) - resp := f.handleDNSQuery(logger, w, query) - if resp == nil { - return - } - - if err := w.WriteMsg(resp); err != nil { - logger.Errorf("failed to write DNS response: %v", err) - return - } - - logger.Tracef("response: domain=%s rcode=%s answers=%s took=%s", - query.Question[0].Name, dns.RcodeToString[resp.Rcode], resutil.FormatAnswers(resp.Answer), time.Since(startTime)) + f.handleDNSQuery(logger, w, query, startTime) } func (f *DNSForwarder) updateInternalState(ips []netip.Addr, mostSpecificResId route.ResID, matchingEntries []*ForwarderEntry) { @@ -334,6 +325,7 @@ func (f *DNSForwarder) handleDNSError( resp *dns.Msg, domain string, result resutil.LookupResult, + startTime time.Time, ) { qType := question.Qtype qTypeName := dns.TypeToString[qType] @@ -343,9 +335,7 @@ func (f *DNSForwarder) handleDNSError( // NotFound: cache negative result and respond if result.Rcode == dns.RcodeNameError || result.Rcode == dns.RcodeSuccess { f.cache.set(domain, question.Qtype, nil) - if writeErr := w.WriteMsg(resp); writeErr != nil { - logger.Errorf("failed to write failure DNS response: %v", writeErr) - } + f.writeResponse(logger, w, resp, domain, startTime) return } @@ -355,9 +345,7 @@ func (f *DNSForwarder) handleDNSError( logger.Debugf("serving cached DNS response after upstream failure: domain=%s type=%s", domain, qTypeName) resp.Answer = append(resp.Answer, resutil.IPsToRRs(domain, ips, f.ttl)...) resp.Rcode = dns.RcodeSuccess - if writeErr := w.WriteMsg(resp); writeErr != nil { - logger.Errorf("failed to write cached DNS response: %v", writeErr) - } + f.writeResponse(logger, w, resp, domain, startTime) return } @@ -365,9 +353,7 @@ func (f *DNSForwarder) handleDNSError( verifyResult := resutil.LookupIP(ctx, f.resolver, resutil.NetworkForQtype(qType), domain, qType) if verifyResult.Rcode == dns.RcodeNameError || verifyResult.Rcode == dns.RcodeSuccess { resp.Rcode = verifyResult.Rcode - if writeErr := w.WriteMsg(resp); writeErr != nil { - logger.Errorf("failed to write failure DNS response: %v", writeErr) - } + f.writeResponse(logger, w, resp, domain, startTime) return } } @@ -375,15 +361,12 @@ func (f *DNSForwarder) handleDNSError( // No cache or verification failed. Log with or without the server field for more context. var dnsErr *net.DNSError if errors.As(result.Err, &dnsErr) && dnsErr.Server != "" { - logger.Warnf("failed to resolve: type=%s domain=%s server=%s: %v", qTypeName, domain, dnsErr.Server, result.Err) + logger.Warnf("upstream failure: type=%s domain=%s server=%s: %v", qTypeName, domain, dnsErr.Server, result.Err) } else { logger.Warnf(errResolveFailed, domain, result.Err) } - // Write final failure response. - if writeErr := w.WriteMsg(resp); writeErr != nil { - logger.Errorf("failed to write failure DNS response: %v", writeErr) - } + f.writeResponse(logger, w, resp, domain, startTime) } // getMatchingEntries retrieves the resource IDs for a given domain. diff --git a/client/internal/dnsfwd/forwarder_test.go b/client/internal/dnsfwd/forwarder_test.go index 6416c2f21..7325ef8a7 100644 --- a/client/internal/dnsfwd/forwarder_test.go +++ b/client/internal/dnsfwd/forwarder_test.go @@ -318,8 +318,9 @@ func TestDNSForwarder_UnauthorizedDomainAccess(t *testing.T) { query.SetQuestion(dns.Fqdn(tt.queryDomain), dns.TypeA) mockWriter := &test.MockResponseWriter{} - resp := forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query) + forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query, time.Now()) + resp := mockWriter.GetLastResponse() if tt.shouldResolve { require.NotNil(t, resp, "Expected response for authorized domain") require.Equal(t, dns.RcodeSuccess, resp.Rcode, "Expected successful response") @@ -329,10 +330,9 @@ func TestDNSForwarder_UnauthorizedDomainAccess(t *testing.T) { mockFirewall.AssertExpectations(t) mockResolver.AssertExpectations(t) } else { - if resp != nil { - assert.True(t, len(resp.Answer) == 0 || resp.Rcode != dns.RcodeSuccess, - "Unauthorized domain should not return successful answers") - } + require.NotNil(t, resp, "Expected response") + assert.True(t, len(resp.Answer) == 0 || resp.Rcode != dns.RcodeSuccess, + "Unauthorized domain should not return successful answers") mockFirewall.AssertNotCalled(t, "UpdateSet") mockResolver.AssertNotCalled(t, "LookupNetIP") } @@ -466,14 +466,16 @@ func TestDNSForwarder_FirewallSetUpdates(t *testing.T) { dnsQuery.SetQuestion(dns.Fqdn(tt.query), dns.TypeA) mockWriter := &test.MockResponseWriter{} - resp := forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, dnsQuery) + forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, dnsQuery, time.Now()) // Verify response + resp := mockWriter.GetLastResponse() if tt.shouldResolve { require.NotNil(t, resp, "Expected response for authorized domain") require.Equal(t, dns.RcodeSuccess, resp.Rcode) require.NotEmpty(t, resp.Answer) - } else if resp != nil { + } else { + require.NotNil(t, resp, "Expected response") assert.True(t, resp.Rcode == dns.RcodeRefused || len(resp.Answer) == 0, "Unauthorized domain should be refused or have no answers") } @@ -528,9 +530,10 @@ func TestDNSForwarder_MultipleIPsInSingleUpdate(t *testing.T) { query.SetQuestion("example.com.", dns.TypeA) mockWriter := &test.MockResponseWriter{} - resp := forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query) + forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query, time.Now()) // Verify response contains all IPs + resp := mockWriter.GetLastResponse() require.NotNil(t, resp) require.Equal(t, dns.RcodeSuccess, resp.Rcode) require.Len(t, resp.Answer, 3, "Should have 3 answer records") @@ -605,7 +608,7 @@ func TestDNSForwarder_ResponseCodes(t *testing.T) { }, } - _ = forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query) + forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query, time.Now()) // Check the response written to the writer require.NotNil(t, writtenResp, "Expected response to be written") @@ -675,7 +678,8 @@ func TestDNSForwarder_ServeFromCacheOnUpstreamFailure(t *testing.T) { q1 := &dns.Msg{} q1.SetQuestion(dns.Fqdn("example.com"), dns.TypeA) w1 := &test.MockResponseWriter{} - resp1 := forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), w1, q1) + forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), w1, q1, time.Now()) + resp1 := w1.GetLastResponse() require.NotNil(t, resp1) require.Equal(t, dns.RcodeSuccess, resp1.Rcode) require.Len(t, resp1.Answer, 1) @@ -683,13 +687,13 @@ func TestDNSForwarder_ServeFromCacheOnUpstreamFailure(t *testing.T) { // Second query: serve from cache after upstream failure q2 := &dns.Msg{} q2.SetQuestion(dns.Fqdn("example.com"), dns.TypeA) - var writtenResp *dns.Msg - w2 := &test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { writtenResp = m; return nil }} - _ = forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), w2, q2) + w2 := &test.MockResponseWriter{} + forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), w2, q2, time.Now()) - require.NotNil(t, writtenResp, "expected response to be written") - require.Equal(t, dns.RcodeSuccess, writtenResp.Rcode) - require.Len(t, writtenResp.Answer, 1) + resp2 := w2.GetLastResponse() + require.NotNil(t, resp2, "expected response to be written") + require.Equal(t, dns.RcodeSuccess, resp2.Rcode) + require.Len(t, resp2.Answer, 1) mockResolver.AssertExpectations(t) } @@ -715,7 +719,8 @@ func TestDNSForwarder_CacheNormalizationCasingAndDot(t *testing.T) { q1 := &dns.Msg{} q1.SetQuestion(mixedQuery+".", dns.TypeA) w1 := &test.MockResponseWriter{} - resp1 := forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), w1, q1) + forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), w1, q1, time.Now()) + resp1 := w1.GetLastResponse() require.NotNil(t, resp1) require.Equal(t, dns.RcodeSuccess, resp1.Rcode) require.Len(t, resp1.Answer, 1) @@ -727,13 +732,13 @@ func TestDNSForwarder_CacheNormalizationCasingAndDot(t *testing.T) { q2 := &dns.Msg{} q2.SetQuestion("EXAMPLE.COM", dns.TypeA) - var writtenResp *dns.Msg - w2 := &test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { writtenResp = m; return nil }} - _ = forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), w2, q2) + w2 := &test.MockResponseWriter{} + forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), w2, q2, time.Now()) - require.NotNil(t, writtenResp) - require.Equal(t, dns.RcodeSuccess, writtenResp.Rcode) - require.Len(t, writtenResp.Answer, 1) + resp2 := w2.GetLastResponse() + require.NotNil(t, resp2) + require.Equal(t, dns.RcodeSuccess, resp2.Rcode) + require.Len(t, resp2.Answer, 1) mockResolver.AssertExpectations(t) } @@ -784,8 +789,9 @@ func TestDNSForwarder_MultipleOverlappingPatterns(t *testing.T) { query.SetQuestion("smtp.mail.example.com.", dns.TypeA) mockWriter := &test.MockResponseWriter{} - resp := forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query) + forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query, time.Now()) + resp := mockWriter.GetLastResponse() require.NotNil(t, resp) assert.Equal(t, dns.RcodeSuccess, resp.Rcode) @@ -897,26 +903,15 @@ func TestDNSForwarder_NodataVsNxdomain(t *testing.T) { query := &dns.Msg{} query.SetQuestion(dns.Fqdn("example.com"), tt.queryType) - var writtenResp *dns.Msg - mockWriter := &test.MockResponseWriter{ - WriteMsgFunc: func(m *dns.Msg) error { - writtenResp = m - return nil - }, - } + mockWriter := &test.MockResponseWriter{} + forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query, time.Now()) - resp := forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query) - - // If a response was returned, it means it should be written (happens in wrapper functions) - if resp != nil && writtenResp == nil { - writtenResp = resp - } - - require.NotNil(t, writtenResp, "Expected response to be written") - assert.Equal(t, tt.expectedCode, writtenResp.Rcode, tt.description) + resp := mockWriter.GetLastResponse() + require.NotNil(t, resp, "Expected response to be written") + assert.Equal(t, tt.expectedCode, resp.Rcode, tt.description) if tt.expectNoAnswer { - assert.Empty(t, writtenResp.Answer, "Response should have no answer records") + assert.Empty(t, resp.Answer, "Response should have no answer records") } mockResolver.AssertExpectations(t) @@ -931,15 +926,8 @@ func TestDNSForwarder_EmptyQuery(t *testing.T) { query := &dns.Msg{} // Don't set any question - writeCalled := false - mockWriter := &test.MockResponseWriter{ - WriteMsgFunc: func(m *dns.Msg) error { - writeCalled = true - return nil - }, - } - resp := forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query) + mockWriter := &test.MockResponseWriter{} + forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query, time.Now()) - assert.Nil(t, resp, "Should return nil for empty query") - assert.False(t, writeCalled, "Should not write response for empty query") + assert.Nil(t, mockWriter.GetLastResponse(), "Should not write response for empty query") } diff --git a/client/internal/engine.go b/client/internal/engine.go index 68b1f327f..3d72de5a7 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -29,12 +29,14 @@ import ( firewallManager "github.com/netbirdio/netbird/client/firewall/manager" "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/iface/device" + nbnetstack "github.com/netbirdio/netbird/client/iface/netstack" "github.com/netbirdio/netbird/client/iface/udpmux" "github.com/netbirdio/netbird/client/internal/acl" "github.com/netbirdio/netbird/client/internal/debug" "github.com/netbirdio/netbird/client/internal/dns" dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config" "github.com/netbirdio/netbird/client/internal/dnsfwd" + "github.com/netbirdio/netbird/client/internal/expose" "github.com/netbirdio/netbird/client/internal/ingressgw" "github.com/netbirdio/netbird/client/internal/netflow" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" @@ -50,16 +52,14 @@ import ( "github.com/netbirdio/netbird/client/internal/routemanager" "github.com/netbirdio/netbird/client/internal/routemanager/systemops" "github.com/netbirdio/netbird/client/internal/statemanager" - "github.com/netbirdio/netbird/client/internal/updatemanager" + "github.com/netbirdio/netbird/client/internal/updater" "github.com/netbirdio/netbird/client/jobexec" cProto "github.com/netbirdio/netbird/client/proto" - "github.com/netbirdio/netbird/shared/management/domain" - semaphoregroup "github.com/netbirdio/netbird/util/semaphore-group" - "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" mgmProto "github.com/netbirdio/netbird/shared/management/proto" auth "github.com/netbirdio/netbird/shared/relay/auth/hmac" relayClient "github.com/netbirdio/netbird/shared/relay/client" @@ -75,13 +75,11 @@ import ( const ( PeerConnectionTimeoutMax = 45000 // ms PeerConnectionTimeoutMin = 30000 // ms - connInitLimit = 200 disableAutoUpdate = "disabled" ) var ErrResetConnection = fmt.Errorf("reset connection") -// EngineConfig is a config for the Engine type EngineConfig struct { WgPort int WgIfaceName string @@ -143,6 +141,17 @@ type EngineConfig struct { LogPath string } +// EngineServices holds the external service dependencies required by the Engine. +type EngineServices struct { + SignalClient signal.Client + MgmClient mgm.Client + RelayManager *relayClient.Manager + StatusRecorder *peer.Status + Checks []*mgmProto.Checks + StateManager *statemanager.Manager + UpdateManager *updater.Manager +} + // Engine is a mechanism responsible for reacting on Signal and Management stream events and managing connections to the remote peers. type Engine struct { // signal is a Signal Service client @@ -209,11 +218,10 @@ type Engine struct { syncRespMux sync.RWMutex persistSyncResponse bool latestSyncResponse *mgmProto.SyncResponse - connSemaphore *semaphoregroup.SemaphoreGroup flowManager nftypes.FlowManager // auto-update - updateManager *updatemanager.Manager + updateManager *updater.Manager // WireGuard interface monitor wgIfaceMonitor *WGIfaceMonitor @@ -225,6 +233,8 @@ type Engine struct { jobExecutor *jobexec.Executor jobExecutorWG sync.WaitGroup + + exposeManager *expose.Manager } // Peer is an instance of the Connection Peer @@ -241,22 +251,17 @@ type localIpUpdater interface { func NewEngine( clientCtx context.Context, clientCancel context.CancelFunc, - signalClient signal.Client, - mgmClient mgm.Client, - relayManager *relayClient.Manager, config *EngineConfig, + services EngineServices, mobileDep MobileDependency, - statusRecorder *peer.Status, - checks []*mgmProto.Checks, - stateManager *statemanager.Manager, ) *Engine { engine := &Engine{ clientCtx: clientCtx, clientCancel: clientCancel, - signal: signalClient, - signaler: peer.NewSignaler(signalClient, config.WgPrivateKey), - mgmClient: mgmClient, - relayManager: relayManager, + signal: services.SignalClient, + signaler: peer.NewSignaler(services.SignalClient, config.WgPrivateKey), + mgmClient: services.MgmClient, + relayManager: services.RelayManager, peerStore: peerstore.NewConnStore(), syncMsgMux: &sync.Mutex{}, config: config, @@ -264,13 +269,12 @@ func NewEngine( STUNs: []*stun.URI{}, TURNs: []*stun.URI{}, networkSerial: 0, - statusRecorder: statusRecorder, - stateManager: stateManager, + statusRecorder: services.StatusRecorder, + stateManager: services.StateManager, portForwardManager: portforward.NewManager(), - checks: checks, - connSemaphore: semaphoregroup.NewSemaphoreGroup(connInitLimit), + checks: services.Checks, probeStunTurn: relay.NewStunTurnProbe(relay.DefaultCacheTTL), - jobExecutor: jobexec.NewExecutor(), + jobExecutor: jobexec.NewExecutor(), updateManager: services.UpdateManager, } log.Infof("I am: %s", config.WgPrivateKey.PublicKey().String()) @@ -313,7 +317,7 @@ func (e *Engine) Stop() error { } if e.updateManager != nil { - e.updateManager.Stop() + e.updateManager.SetDownloadOnly() } log.Info("cleaning up status recorder states") @@ -421,6 +425,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) e.cancel() } e.ctx, e.cancel = context.WithCancel(e.clientCtx) + e.exposeManager = expose.NewManager(e.ctx, e.mgmClient) wgIface, err := e.newWgIface() if err != nil { @@ -553,11 +558,12 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) // monitor WireGuard interface lifecycle and restart engine on changes e.wgIfaceMonitor = NewWGIfaceMonitor() e.shutdownWg.Add(1) + wgIfaceName := e.wgInterface.Name() go func() { defer e.shutdownWg.Done() - if shouldRestart, err := e.wgIfaceMonitor.Start(e.ctx, e.wgInterface.Name()); shouldRestart { + if shouldRestart, err := e.wgIfaceMonitor.Start(e.ctx, wgIfaceName); shouldRestart { log.Infof("WireGuard interface monitor: %s, restarting engine", err) e.triggerClientRestart() } else if err != nil { @@ -568,13 +574,6 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) return nil } -func (e *Engine) InitialUpdateHandling(autoUpdateSettings *mgmProto.AutoUpdateSettings) { - e.syncMsgMux.Lock() - defer e.syncMsgMux.Unlock() - - e.handleAutoUpdateVersion(autoUpdateSettings, true) -} - func (e *Engine) createFirewall() error { if e.config.DisableFirewall { log.Infof("firewall is disabled") @@ -583,9 +582,11 @@ func (e *Engine) createFirewall() error { var err error e.firewall, err = firewall.NewFirewall(e.wgInterface, e.stateManager, e.flowManager.GetLogger(), e.config.DisableServerRoutes, e.config.MTU) - if err != nil || e.firewall == nil { - log.Errorf("failed creating firewall manager: %s", err) - return nil + if err != nil { + return fmt.Errorf("create firewall manager: %w", err) + } + if e.firewall == nil { + return fmt.Errorf("create firewall manager: received nil manager") } if err := e.initFirewall(); err != nil { @@ -800,42 +801,29 @@ func (e *Engine) PopulateNetbirdConfig(netbirdConfig *mgmProto.NetbirdConfig, mg return nil } -func (e *Engine) handleAutoUpdateVersion(autoUpdateSettings *mgmProto.AutoUpdateSettings, initialCheck bool) { +func (e *Engine) handleAutoUpdateVersion(autoUpdateSettings *mgmProto.AutoUpdateSettings) { + if e.updateManager == nil { + return + } + if autoUpdateSettings == nil { return } - disabled := autoUpdateSettings.Version == disableAutoUpdate - - // Stop and cleanup if disabled - if e.updateManager != nil && disabled { - log.Infof("auto-update is disabled, stopping update manager") - e.updateManager.Stop() - e.updateManager = nil + if autoUpdateSettings.Version == disableAutoUpdate { + log.Infof("auto-update is disabled") + e.updateManager.SetDownloadOnly() return } - // Skip check unless AlwaysUpdate is enabled or this is the initial check at startup - if !autoUpdateSettings.AlwaysUpdate && !initialCheck { - log.Debugf("skipping auto-update check, AlwaysUpdate is false and this is not the initial check") - return - } - - // Start manager if needed - if e.updateManager == nil { - log.Infof("starting auto-update manager") - updateManager, err := updatemanager.NewManager(e.statusRecorder, e.stateManager) - if err != nil { - return - } - e.updateManager = updateManager - e.updateManager.Start(e.ctx) - } - log.Infof("handling auto-update version: %s", autoUpdateSettings.Version) - e.updateManager.SetVersion(autoUpdateSettings.Version) + e.updateManager.SetVersion(autoUpdateSettings.Version, autoUpdateSettings.AlwaysUpdate) } func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { + started := time.Now() + defer func() { + log.Infof("sync finished in %s", time.Since(started)) + }() e.syncMsgMux.Lock() defer e.syncMsgMux.Unlock() @@ -845,7 +833,7 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { } if update.NetworkMap != nil && update.NetworkMap.PeerConfig != nil { - e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate, false) + e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate) } if update.GetNetbirdConfig() != nil { @@ -1025,7 +1013,7 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error { state := e.statusRecorder.GetLocalPeerState() state.IP = e.wgInterface.Address().String() state.PubKey = e.config.WgPrivateKey.PublicKey().String() - state.KernelInterface = device.WireGuardModuleIsLoaded() + state.KernelInterface = !e.wgInterface.IsUserspaceBind() state.FQDN = conf.GetFqdn() e.statusRecorder.UpdateLocalPeerState(state) @@ -1318,8 +1306,7 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error { // Test received (upstream) servers for availability right away instead of upon usage. // If no server of a server group responds this will disable the respective handler and retry later. - e.dnsServer.ProbeAvailability() - + go e.dnsServer.ProbeAvailability() return nil } @@ -1541,7 +1528,6 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV IFaceDiscover: e.mobileDep.IFaceDiscover, RelayManager: e.relayManager, SrWatcher: e.srWatcher, - Semaphore: e.connSemaphore, PortForwardManager: e.portForwardManager, } peerConn, err := peer.NewConn(config, serviceDependencies) @@ -1565,8 +1551,10 @@ func (e *Engine) receiveSignalEvents() { defer e.shutdownWg.Done() // connect to a stream of messages coming from the signal server err := e.signal.Receive(e.ctx, func(msg *sProto.Message) error { + start := time.Now() e.syncMsgMux.Lock() defer e.syncMsgMux.Unlock() + gotLock := time.Since(start) // Check context INSIDE lock to ensure atomicity with shutdown if e.ctx.Err() != nil { @@ -1590,6 +1578,8 @@ func (e *Engine) receiveSignalEvents() { return err } + log.Debugf("receiveMSG: took %s to get lock for peer %s with session id %s", gotLock, msg.Key, offerAnswer.SessionID) + if msg.Body.Type == sProto.Body_OFFER { conn.OnRemoteOffer(*offerAnswer) } else { @@ -1829,11 +1819,18 @@ func (e *Engine) GetRouteManager() routemanager.Manager { return e.routeManager } -// GetFirewallManager returns the firewall manager +// GetFirewallManager returns the firewall manager. func (e *Engine) GetFirewallManager() firewallManager.Manager { return e.firewall } +// GetExposeManager returns the expose session manager. +func (e *Engine) GetExposeManager() *expose.Manager { + e.syncMsgMux.Lock() + defer e.syncMsgMux.Unlock() + return e.exposeManager +} + func findIPFromInterfaceName(ifaceName string) (net.IP, error) { iface, err := net.InterfaceByName(ifaceName) if err != nil { @@ -1933,7 +1930,7 @@ func (e *Engine) triggerClientRestart() { } func (e *Engine) startNetworkMonitor() { - if !e.config.NetworkMonitor { + if !e.config.NetworkMonitor || nbnetstack.IsEnabled() { log.Infof("Network monitor is disabled, not starting") return } diff --git a/client/internal/engine_ssh.go b/client/internal/engine_ssh.go index a8c05fe0a..1419bc262 100644 --- a/client/internal/engine_ssh.go +++ b/client/internal/engine_ssh.go @@ -10,6 +10,7 @@ import ( log "github.com/sirupsen/logrus" firewallManager "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/iface/netstack" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" sshauth "github.com/netbirdio/netbird/client/ssh/auth" sshconfig "github.com/netbirdio/netbird/client/ssh/config" @@ -94,6 +95,10 @@ func (e *Engine) updateSSH(sshConf *mgmProto.SSHConfig) error { // updateSSHClientConfig updates the SSH client configuration with peer information func (e *Engine) updateSSHClientConfig(remotePeers []*mgmProto.RemotePeerConfig) error { + if netstack.IsEnabled() { + return nil + } + peerInfo := e.extractPeerSSHInfo(remotePeers) if len(peerInfo) == 0 { log.Debug("no SSH-enabled peers found, skipping SSH config update") @@ -216,6 +221,10 @@ func (e *Engine) GetPeerSSHKey(peerAddress string) ([]byte, bool) { // cleanupSSHConfig removes NetBird SSH client configuration on shutdown func (e *Engine) cleanupSSHConfig() { + if netstack.IsEnabled() { + return + } + configMgr := sshconfig.New() if err := configMgr.RemoveSSHClientConfig(); err != nil { diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go index 012c8ad6e..f9e7f8fa0 100644 --- a/client/internal/engine_test.go +++ b/client/internal/engine_test.go @@ -251,9 +251,6 @@ func TestEngine_SSH(t *testing.T) { relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) engine := NewEngine( ctx, cancel, - &signal.MockClient{}, - &mgmt.MockClient{}, - relayMgr, &EngineConfig{ WgIfaceName: "utun101", WgAddr: "100.64.0.1/24", @@ -263,10 +260,13 @@ func TestEngine_SSH(t *testing.T) { MTU: iface.DefaultMTU, SSHKey: sshKey, }, + EngineServices{ + SignalClient: &signal.MockClient{}, + MgmClient: &mgmt.MockClient{}, + RelayManager: relayMgr, + StatusRecorder: peer.NewRecorder("https://mgm"), + }, MobileDependency{}, - peer.NewRecorder("https://mgm"), - nil, - nil, ) engine.dnsServer = &dns.MockServer{ @@ -428,13 +428,18 @@ func TestEngine_UpdateNetworkMap(t *testing.T) { defer cancel() relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) - engine := NewEngine(ctx, cancel, &signal.MockClient{}, &mgmt.MockClient{}, relayMgr, &EngineConfig{ + engine := NewEngine(ctx, cancel, &EngineConfig{ WgIfaceName: "utun102", WgAddr: "100.64.0.1/24", WgPrivateKey: key, WgPort: 33100, MTU: iface.DefaultMTU, - }, MobileDependency{}, peer.NewRecorder("https://mgm"), nil, nil) + }, EngineServices{ + SignalClient: &signal.MockClient{}, + MgmClient: &mgmt.MockClient{}, + RelayManager: relayMgr, + StatusRecorder: peer.NewRecorder("https://mgm"), + }, MobileDependency{}) wgIface := &MockWGIface{ NameFunc: func() string { return "utun102" }, @@ -647,13 +652,18 @@ func TestEngine_Sync(t *testing.T) { return nil } relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) - engine := NewEngine(ctx, cancel, &signal.MockClient{}, &mgmt.MockClient{SyncFunc: syncFunc}, relayMgr, &EngineConfig{ + engine := NewEngine(ctx, cancel, &EngineConfig{ WgIfaceName: "utun103", WgAddr: "100.64.0.1/24", WgPrivateKey: key, WgPort: 33100, MTU: iface.DefaultMTU, - }, MobileDependency{}, peer.NewRecorder("https://mgm"), nil, nil) + }, EngineServices{ + SignalClient: &signal.MockClient{}, + MgmClient: &mgmt.MockClient{SyncFunc: syncFunc}, + RelayManager: relayMgr, + StatusRecorder: peer.NewRecorder("https://mgm"), + }, MobileDependency{}) engine.ctx = ctx engine.dnsServer = &dns.MockServer{ @@ -812,13 +822,18 @@ func TestEngine_UpdateNetworkMapWithRoutes(t *testing.T) { wgAddr := fmt.Sprintf("100.66.%d.1/24", n) relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) - engine := NewEngine(ctx, cancel, &signal.MockClient{}, &mgmt.MockClient{}, relayMgr, &EngineConfig{ + engine := NewEngine(ctx, cancel, &EngineConfig{ WgIfaceName: wgIfaceName, WgAddr: wgAddr, WgPrivateKey: key, WgPort: 33100, MTU: iface.DefaultMTU, - }, MobileDependency{}, peer.NewRecorder("https://mgm"), nil, nil) + }, EngineServices{ + SignalClient: &signal.MockClient{}, + MgmClient: &mgmt.MockClient{}, + RelayManager: relayMgr, + StatusRecorder: peer.NewRecorder("https://mgm"), + }, MobileDependency{}) engine.ctx = ctx newNet, err := stdnet.NewNet(context.Background(), nil) if err != nil { @@ -1014,13 +1029,18 @@ func TestEngine_UpdateNetworkMapWithDNSUpdate(t *testing.T) { wgAddr := fmt.Sprintf("100.66.%d.1/24", n) relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) - engine := NewEngine(ctx, cancel, &signal.MockClient{}, &mgmt.MockClient{}, relayMgr, &EngineConfig{ + engine := NewEngine(ctx, cancel, &EngineConfig{ WgIfaceName: wgIfaceName, WgAddr: wgAddr, WgPrivateKey: key, WgPort: 33100, MTU: iface.DefaultMTU, - }, MobileDependency{}, peer.NewRecorder("https://mgm"), nil, nil) + }, EngineServices{ + SignalClient: &signal.MockClient{}, + MgmClient: &mgmt.MockClient{}, + RelayManager: relayMgr, + StatusRecorder: peer.NewRecorder("https://mgm"), + }, MobileDependency{}) engine.ctx = ctx newNet, err := stdnet.NewNet(context.Background(), nil) @@ -1546,7 +1566,12 @@ func createEngine(ctx context.Context, cancel context.CancelFunc, setupKey strin } relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) - e, err := NewEngine(ctx, cancel, signalClient, mgmtClient, relayMgr, conf, MobileDependency{}, peer.NewRecorder("https://mgm"), nil, nil), nil + e, err := NewEngine(ctx, cancel, conf, EngineServices{ + SignalClient: signalClient, + MgmClient: mgmtClient, + RelayManager: relayMgr, + StatusRecorder: peer.NewRecorder("https://mgm"), + }, MobileDependency{}), nil e.ctx = ctx return e, err } diff --git a/client/internal/expose/manager.go b/client/internal/expose/manager.go new file mode 100644 index 000000000..c59a1a7bd --- /dev/null +++ b/client/internal/expose/manager.go @@ -0,0 +1,97 @@ +package expose + +import ( + "context" + "time" + + mgm "github.com/netbirdio/netbird/shared/management/client" + log "github.com/sirupsen/logrus" +) + +const renewTimeout = 10 * time.Second + +// Response holds the response from exposing a service. +type Response struct { + ServiceName string + ServiceURL string + Domain string + PortAutoAssigned bool +} + +type Request struct { + NamePrefix string + Domain string + Port uint16 + Protocol int + Pin string + Password string + UserGroups []string + ListenPort uint16 +} + +type ManagementClient interface { + CreateExpose(ctx context.Context, req mgm.ExposeRequest) (*mgm.ExposeResponse, error) + RenewExpose(ctx context.Context, domain string) error + StopExpose(ctx context.Context, domain string) error +} + +// Manager handles expose session lifecycle via the management client. +type Manager struct { + mgmClient ManagementClient + ctx context.Context +} + +// NewManager creates a new expose Manager using the given management client. +func NewManager(ctx context.Context, mgmClient ManagementClient) *Manager { + return &Manager{mgmClient: mgmClient, ctx: ctx} +} + +// Expose creates a new expose session via the management server. +func (m *Manager) Expose(ctx context.Context, req Request) (*Response, error) { + log.Infof("exposing service on port %d", req.Port) + resp, err := m.mgmClient.CreateExpose(ctx, toClientExposeRequest(req)) + if err != nil { + return nil, err + } + + log.Infof("expose session created for %s", resp.Domain) + + return fromClientExposeResponse(resp), nil +} + +func (m *Manager) KeepAlive(ctx context.Context, domain string) error { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + defer m.stop(domain) + + for { + select { + case <-ctx.Done(): + log.Infof("context canceled, stopping keep alive for %s", domain) + + return nil + case <-ticker.C: + if err := m.renew(ctx, domain); err != nil { + log.Errorf("renewing expose session for %s: %v", domain, err) + return err + } + } + } +} + +// renew extends the TTL of an active expose session. +func (m *Manager) renew(ctx context.Context, domain string) error { + renewCtx, cancel := context.WithTimeout(ctx, renewTimeout) + defer cancel() + return m.mgmClient.RenewExpose(renewCtx, domain) +} + +// stop terminates an active expose session. +func (m *Manager) stop(domain string) { + stopCtx, cancel := context.WithTimeout(m.ctx, renewTimeout) + defer cancel() + err := m.mgmClient.StopExpose(stopCtx, domain) + if err != nil { + log.Warnf("Failed stopping expose session for %s: %v", domain, err) + } +} diff --git a/client/internal/expose/manager_test.go b/client/internal/expose/manager_test.go new file mode 100644 index 000000000..87d43cdb0 --- /dev/null +++ b/client/internal/expose/manager_test.go @@ -0,0 +1,95 @@ +package expose + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + daemonProto "github.com/netbirdio/netbird/client/proto" + mgm "github.com/netbirdio/netbird/shared/management/client" +) + +func TestManager_Expose_Success(t *testing.T) { + mock := &mgm.MockClient{ + CreateExposeFunc: func(ctx context.Context, req mgm.ExposeRequest) (*mgm.ExposeResponse, error) { + return &mgm.ExposeResponse{ + ServiceName: "my-service", + ServiceURL: "https://my-service.example.com", + Domain: "my-service.example.com", + }, nil + }, + } + + m := NewManager(context.Background(), mock) + result, err := m.Expose(context.Background(), Request{Port: 8080}) + require.NoError(t, err) + assert.Equal(t, "my-service", result.ServiceName, "service name should match") + assert.Equal(t, "https://my-service.example.com", result.ServiceURL, "service URL should match") + assert.Equal(t, "my-service.example.com", result.Domain, "domain should match") +} + +func TestManager_Expose_Error(t *testing.T) { + mock := &mgm.MockClient{ + CreateExposeFunc: func(ctx context.Context, req mgm.ExposeRequest) (*mgm.ExposeResponse, error) { + return nil, errors.New("permission denied") + }, + } + + m := NewManager(context.Background(), mock) + _, err := m.Expose(context.Background(), Request{Port: 8080}) + require.Error(t, err) + assert.Contains(t, err.Error(), "permission denied", "error should propagate") +} + +func TestManager_Renew_Success(t *testing.T) { + mock := &mgm.MockClient{ + RenewExposeFunc: func(ctx context.Context, domain string) error { + assert.Equal(t, "my-service.example.com", domain, "domain should be passed through") + return nil + }, + } + + m := NewManager(context.Background(), mock) + err := m.renew(context.Background(), "my-service.example.com") + require.NoError(t, err) +} + +func TestManager_Renew_Timeout(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + mock := &mgm.MockClient{ + RenewExposeFunc: func(ctx context.Context, domain string) error { + return ctx.Err() + }, + } + + m := NewManager(ctx, mock) + err := m.renew(ctx, "my-service.example.com") + require.Error(t, err) +} + +func TestNewRequest(t *testing.T) { + req := &daemonProto.ExposeServiceRequest{ + Port: 8080, + Protocol: daemonProto.ExposeProtocol_EXPOSE_HTTPS, + Pin: "123456", + Password: "secret", + UserGroups: []string{"group1", "group2"}, + Domain: "custom.example.com", + NamePrefix: "my-prefix", + } + + exposeReq := NewRequest(req) + + assert.Equal(t, uint16(8080), exposeReq.Port, "port should match") + assert.Equal(t, int(daemonProto.ExposeProtocol_EXPOSE_HTTPS), exposeReq.Protocol, "protocol should match") + assert.Equal(t, "123456", exposeReq.Pin, "pin should match") + assert.Equal(t, "secret", exposeReq.Password, "password should match") + assert.Equal(t, []string{"group1", "group2"}, exposeReq.UserGroups, "user groups should match") + assert.Equal(t, "custom.example.com", exposeReq.Domain, "domain should match") + assert.Equal(t, "my-prefix", exposeReq.NamePrefix, "name prefix should match") +} diff --git a/client/internal/expose/request.go b/client/internal/expose/request.go new file mode 100644 index 000000000..bff4f2ce7 --- /dev/null +++ b/client/internal/expose/request.go @@ -0,0 +1,42 @@ +package expose + +import ( + daemonProto "github.com/netbirdio/netbird/client/proto" + mgm "github.com/netbirdio/netbird/shared/management/client" +) + +// NewRequest converts a daemon ExposeServiceRequest to a management ExposeServiceRequest. +func NewRequest(req *daemonProto.ExposeServiceRequest) *Request { + return &Request{ + Port: uint16(req.Port), + Protocol: int(req.Protocol), + Pin: req.Pin, + Password: req.Password, + UserGroups: req.UserGroups, + Domain: req.Domain, + NamePrefix: req.NamePrefix, + ListenPort: uint16(req.ListenPort), + } +} + +func toClientExposeRequest(req Request) mgm.ExposeRequest { + return mgm.ExposeRequest{ + NamePrefix: req.NamePrefix, + Domain: req.Domain, + Port: req.Port, + Protocol: req.Protocol, + Pin: req.Pin, + Password: req.Password, + UserGroups: req.UserGroups, + ListenPort: req.ListenPort, + } +} + +func fromClientExposeResponse(response *mgm.ExposeResponse) *Response { + return &Response{ + ServiceName: response.ServiceName, + Domain: response.Domain, + ServiceURL: response.ServiceURL, + PortAutoAssigned: response.PortAutoAssigned, + } +} diff --git a/client/internal/lazyconn/activity/manager.go b/client/internal/lazyconn/activity/manager.go index db283ec9a..1c11378c8 100644 --- a/client/internal/lazyconn/activity/manager.go +++ b/client/internal/lazyconn/activity/manager.go @@ -11,6 +11,7 @@ import ( log "github.com/sirupsen/logrus" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + "github.com/netbirdio/netbird/client/iface/netstack" "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/internal/lazyconn" peerid "github.com/netbirdio/netbird/client/internal/peer/id" @@ -74,12 +75,13 @@ func (m *Manager) createListener(peerCfg lazyconn.PeerConfig) (listener, error) return NewUDPListener(m.wgIface, peerCfg) } - // BindListener is only used on Windows and JS platforms: + // BindListener is used on Windows, JS, and netstack platforms: // - JS: Cannot listen to UDP sockets // - Windows: IP_UNICAST_IF socket option forces packets out the interface the default // gateway points to, preventing them from reaching the loopback interface. - // BindListener bypasses this by passing data directly through the bind. - if runtime.GOOS != "windows" && runtime.GOOS != "js" { + // - Netstack: Allows multiple instances on the same host without port conflicts. + // BindListener bypasses these issues by passing data directly through the bind. + if runtime.GOOS != "windows" && runtime.GOOS != "js" && !netstack.IsEnabled() { return NewUDPListener(m.wgIface, peerCfg) } diff --git a/client/internal/networkmonitor/check_change_common.go b/client/internal/networkmonitor/check_change_common.go index c287236e8..a4a4f76ac 100644 --- a/client/internal/networkmonitor/check_change_common.go +++ b/client/internal/networkmonitor/check_change_common.go @@ -22,51 +22,56 @@ func prepareFd() (int, error) { func routeCheck(ctx context.Context, fd int, nexthopv4, nexthopv6 systemops.Nexthop) error { for { - select { - case <-ctx.Done(): - return ctx.Err() - default: - buf := make([]byte, 2048) - n, err := unix.Read(fd, buf) + // Wait until fd is readable or context is cancelled, to avoid a busy-loop + // when the routing socket returns EAGAIN (e.g. immediately after wakeup). + if err := waitReadable(ctx, fd); err != nil { + return err + } + + buf := make([]byte, 2048) + n, err := unix.Read(fd, buf) + if err != nil { + if errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EINTR) { + continue + } + if errors.Is(err, unix.EBADF) || errors.Is(err, unix.EINVAL) { + return fmt.Errorf("routing socket closed: %w", err) + } + return fmt.Errorf("read routing socket: %w", err) + } + + if n < unix.SizeofRtMsghdr { + log.Debugf("Network monitor: read from routing socket returned less than expected: %d bytes", n) + continue + } + + msg := (*unix.RtMsghdr)(unsafe.Pointer(&buf[0])) + + switch msg.Type { + // handle route changes + case unix.RTM_ADD, syscall.RTM_DELETE: + route, err := parseRouteMessage(buf[:n]) if err != nil { - if !errors.Is(err, unix.EBADF) && !errors.Is(err, unix.EINVAL) { - log.Warnf("Network monitor: failed to read from routing socket: %v", err) - } - continue - } - if n < unix.SizeofRtMsghdr { - log.Debugf("Network monitor: read from routing socket returned less than expected: %d bytes", n) + log.Debugf("Network monitor: error parsing routing message: %v", err) continue } - msg := (*unix.RtMsghdr)(unsafe.Pointer(&buf[0])) + if route.Dst.Bits() != 0 { + continue + } + intf := "" + if route.Interface != nil { + intf = route.Interface.Name + } switch msg.Type { - // handle route changes - case unix.RTM_ADD, syscall.RTM_DELETE: - route, err := parseRouteMessage(buf[:n]) - if err != nil { - log.Debugf("Network monitor: error parsing routing message: %v", err) - continue - } - - if route.Dst.Bits() != 0 { - continue - } - - intf := "" - if route.Interface != nil { - intf = route.Interface.Name - } - switch msg.Type { - case unix.RTM_ADD: - log.Infof("Network monitor: default route changed: via %s, interface %s", route.Gw, intf) + case unix.RTM_ADD: + log.Infof("Network monitor: default route changed: via %s, interface %s", route.Gw, intf) + return nil + case unix.RTM_DELETE: + if nexthopv4.Intf != nil && route.Gw.Compare(nexthopv4.IP) == 0 || nexthopv6.Intf != nil && route.Gw.Compare(nexthopv6.IP) == 0 { + log.Infof("Network monitor: default route removed: via %s, interface %s", route.Gw, intf) return nil - case unix.RTM_DELETE: - if nexthopv4.Intf != nil && route.Gw.Compare(nexthopv4.IP) == 0 || nexthopv6.Intf != nil && route.Gw.Compare(nexthopv6.IP) == 0 { - log.Infof("Network monitor: default route removed: via %s, interface %s", route.Gw, intf) - return nil - } } } } @@ -90,3 +95,33 @@ func parseRouteMessage(buf []byte) (*systemops.Route, error) { return systemops.MsgToRoute(msg) } + +// waitReadable blocks until fd has data to read, or ctx is cancelled. +func waitReadable(ctx context.Context, fd int) error { + var fdset unix.FdSet + if fd < 0 || fd/unix.NFDBITS >= len(fdset.Bits) { + return fmt.Errorf("fd %d out of range for FdSet", fd) + } + + for { + if err := ctx.Err(); err != nil { + return err + } + + fdset = unix.FdSet{} + fdset.Set(fd) + // Use a 1-second timeout so we can re-check ctx periodically. + tv := unix.Timeval{Sec: 1} + n, err := unix.Select(fd+1, &fdset, nil, nil, &tv) + if err != nil { + if errors.Is(err, unix.EINTR) { + continue + } + return fmt.Errorf("select on routing socket: %w", err) + } + if n > 0 { + return nil + } + // timeout — loop back and re-check ctx + } +} diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go index 4a9e2855a..0f213a6fb 100644 --- a/client/internal/peer/conn.go +++ b/client/internal/peer/conn.go @@ -3,7 +3,6 @@ package peer import ( "context" "fmt" - "math/rand" "net" "net/netip" "runtime" @@ -26,7 +25,6 @@ import ( "github.com/netbirdio/netbird/client/internal/stdnet" "github.com/netbirdio/netbird/route" relayClient "github.com/netbirdio/netbird/shared/relay/client" - semaphoregroup "github.com/netbirdio/netbird/util/semaphore-group" ) type ServiceDependencies struct { @@ -35,7 +33,6 @@ type ServiceDependencies struct { IFaceDiscover stdnet.ExternalIFaceDiscover RelayManager *relayClient.Manager SrWatcher *guard.SRWatcher - Semaphore *semaphoregroup.SemaphoreGroup PeerConnDispatcher *dispatcher.ConnectionDispatcher PortForwardManager *portforward.Manager } @@ -114,9 +111,8 @@ type Conn struct { wgProxyRelay wgproxy.Proxy handshaker *Handshaker - guard *guard.Guard - semaphore *semaphoregroup.SemaphoreGroup - wg sync.WaitGroup + guard *guard.Guard + wg sync.WaitGroup // debug purpose dumpState *stateDump @@ -142,7 +138,6 @@ func NewConn(config ConnConfig, services ServiceDependencies) (*Conn, error) { iFaceDiscover: services.IFaceDiscover, relayManager: services.RelayManager, srWatcher: services.SrWatcher, - semaphore: services.Semaphore, portForwardManager: services.PortForwardManager, statusRelay: worker.NewAtomicStatus(), statusICE: worker.NewAtomicStatus(), @@ -158,15 +153,10 @@ func NewConn(config ConnConfig, services ServiceDependencies) (*Conn, error) { // It will try to establish a connection using ICE and in parallel with relay. The higher priority connection type will // be used. func (conn *Conn) Open(engineCtx context.Context) error { - if err := conn.semaphore.Add(engineCtx); err != nil { - return err - } - conn.mu.Lock() defer conn.mu.Unlock() if conn.opened { - conn.semaphore.Done() return nil } @@ -177,7 +167,6 @@ func (conn *Conn) Open(engineCtx context.Context) error { relayIsSupportedLocally := conn.workerRelay.RelayIsSupportedLocally() workerICE, err := NewWorkerICE(conn.ctx, conn.Log, conn.config, conn, conn.signaler, conn.iFaceDiscover, conn.statusRecorder, relayIsSupportedLocally) if err != nil { - conn.semaphore.Done() return err } conn.workerICE = workerICE @@ -211,10 +200,6 @@ func (conn *Conn) Open(engineCtx context.Context) error { conn.wg.Add(1) go func() { defer conn.wg.Done() - - conn.waitInitialRandomSleepTime(conn.ctx) - conn.semaphore.Done() - conn.guard.Start(conn.ctx, conn.onGuardEvent) }() conn.opened = true @@ -394,6 +379,8 @@ func (conn *Conn) onICEConnectionIsReady(priority conntype.ConnPriority, iceConn } conn.Log.Infof("configure WireGuard endpoint to: %s", ep.String()) + conn.enableWgWatcherIfNeeded() + presharedKey := conn.presharedKey(iceConnInfo.RosenpassPubKey) if err = conn.endpointUpdater.ConfigureWGEndpoint(ep, presharedKey); err != nil { conn.handleConfigurationFailure(err, wgProxy) @@ -406,15 +393,13 @@ func (conn *Conn) onICEConnectionIsReady(priority conntype.ConnPriority, iceConn conn.wgProxyRelay.RedirectAs(ep) } - conn.enableWgWatcherIfNeeded() - conn.currentConnPriority = priority conn.statusICE.SetConnected() conn.updateIceState(iceConnInfo) conn.doOnConnected(iceConnInfo.RosenpassPubKey, iceConnInfo.RosenpassAddr) } -func (conn *Conn) onICEStateDisconnected() { +func (conn *Conn) onICEStateDisconnected(sessionChanged bool) { conn.mu.Lock() defer conn.mu.Unlock() @@ -434,14 +419,18 @@ func (conn *Conn) onICEStateDisconnected() { if conn.isReadyToUpgrade() { conn.Log.Infof("ICE disconnected, set Relay to active connection") conn.dumpState.SwitchToRelay() + if sessionChanged { + conn.resetEndpoint() + } + + // todo consider to move after the ConfigureWGEndpoint conn.wgProxyRelay.Work() presharedKey := conn.presharedKey(conn.rosenpassRemoteKey) - if err := conn.endpointUpdater.ConfigureWGEndpoint(conn.wgProxyRelay.EndpointAddr(), presharedKey); err != nil { + if err := conn.endpointUpdater.SwitchWGEndpoint(conn.wgProxyRelay.EndpointAddr(), presharedKey); err != nil { conn.Log.Errorf("failed to switch to relay conn: %v", err) } - conn.wgProxyRelay.Work() conn.currentConnPriority = conntype.Relay } else { conn.Log.Infof("ICE disconnected, do not switch to Relay. Reset priority to: %s", conntype.None.String()) @@ -503,19 +492,22 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) { return } - wgProxy.Work() - presharedKey := conn.presharedKey(rci.rosenpassPubKey) - if err := conn.endpointUpdater.ConfigureWGEndpoint(wgProxy.EndpointAddr(), presharedKey); err != nil { + controller := isController(conn.config) + + if controller { + wgProxy.Work() + } + conn.enableWgWatcherIfNeeded() + if err := conn.endpointUpdater.ConfigureWGEndpoint(wgProxy.EndpointAddr(), conn.presharedKey(rci.rosenpassPubKey)); err != nil { if err := wgProxy.CloseConn(); err != nil { conn.Log.Warnf("Failed to close relay connection: %v", err) } conn.Log.Errorf("Failed to update WireGuard peer configuration: %v", err) return } - - conn.enableWgWatcherIfNeeded() - - wgConfigWorkaround() + if !controller { + wgProxy.Work() + } conn.rosenpassRemoteKey = rci.rosenpassPubKey conn.currentConnPriority = conntype.Relay conn.statusRelay.SetConnected() @@ -667,19 +659,6 @@ func (conn *Conn) doOnConnected(remoteRosenpassPubKey []byte, remoteRosenpassAdd } } -func (conn *Conn) waitInitialRandomSleepTime(ctx context.Context) { - maxWait := 300 - duration := time.Duration(rand.Intn(maxWait)) * time.Millisecond - - timeout := time.NewTimer(duration) - defer timeout.Stop() - - select { - case <-ctx.Done(): - case <-timeout.C: - } -} - func (conn *Conn) isRelayed() bool { switch conn.currentConnPriority { case conntype.Relay, conntype.ICETurn: @@ -760,6 +739,17 @@ func (conn *Conn) newProxy(remoteConn net.Conn) (wgproxy.Proxy, error) { return wgProxy, nil } +func (conn *Conn) resetEndpoint() { + if !isController(conn.config) { + return + } + conn.Log.Infof("reset wg endpoint") + conn.wgWatcher.Reset() + if err := conn.endpointUpdater.RemoveEndpointAddress(); err != nil { + conn.Log.Warnf("failed to remove endpoint address before update: %v", err) + } +} + func (conn *Conn) isReadyToUpgrade() bool { return conn.wgProxyRelay != nil && conn.currentConnPriority != conntype.Relay } @@ -865,9 +855,3 @@ func isController(config ConnConfig) bool { func isRosenpassEnabled(remoteRosenpassPubKey []byte) bool { return remoteRosenpassPubKey != nil } - -// wgConfigWorkaround is a workaround for the issue with WireGuard configuration update -// When update a peer configuration in near to each other time, the second update can be ignored by WireGuard -func wgConfigWorkaround() { - time.Sleep(100 * time.Millisecond) -} diff --git a/client/internal/peer/conn_test.go b/client/internal/peer/conn_test.go index 32383b530..59216b647 100644 --- a/client/internal/peer/conn_test.go +++ b/client/internal/peer/conn_test.go @@ -15,7 +15,6 @@ import ( "github.com/netbirdio/netbird/client/internal/peer/ice" "github.com/netbirdio/netbird/client/internal/stdnet" "github.com/netbirdio/netbird/util" - semaphoregroup "github.com/netbirdio/netbird/util/semaphore-group" ) var testDispatcher = dispatcher.NewConnectionDispatcher() @@ -53,7 +52,6 @@ func TestConn_GetKey(t *testing.T) { sd := ServiceDependencies{ SrWatcher: swWatcher, - Semaphore: semaphoregroup.NewSemaphoreGroup(1), PeerConnDispatcher: testDispatcher, } conn, err := NewConn(connConf, sd) @@ -71,7 +69,6 @@ func TestConn_OnRemoteOffer(t *testing.T) { sd := ServiceDependencies{ StatusRecorder: NewRecorder("https://mgm"), SrWatcher: swWatcher, - Semaphore: semaphoregroup.NewSemaphoreGroup(1), PeerConnDispatcher: testDispatcher, } conn, err := NewConn(connConf, sd) @@ -110,7 +107,6 @@ func TestConn_OnRemoteAnswer(t *testing.T) { sd := ServiceDependencies{ StatusRecorder: NewRecorder("https://mgm"), SrWatcher: swWatcher, - Semaphore: semaphoregroup.NewSemaphoreGroup(1), PeerConnDispatcher: testDispatcher, } conn, err := NewConn(connConf, sd) diff --git a/client/internal/peer/endpoint.go b/client/internal/peer/endpoint.go index 52d66159c..9ba1efb6e 100644 --- a/client/internal/peer/endpoint.go +++ b/client/internal/peer/endpoint.go @@ -34,28 +34,27 @@ func NewEndpointUpdater(log *logrus.Entry, wgConfig WgConfig, initiator bool) *E } } -// ConfigureWGEndpoint sets up the WireGuard endpoint configuration. -// The initiator immediately configures the endpoint, while the non-initiator -// waits for a fallback period before configuring to avoid handshake congestion. func (e *EndpointUpdater) ConfigureWGEndpoint(addr *net.UDPAddr, presharedKey *wgtypes.Key) error { e.mu.Lock() defer e.mu.Unlock() if e.initiator { - e.log.Debugf("configure up WireGuard as initiatr") - return e.updateWireGuardPeer(addr, presharedKey) + e.log.Debugf("configure up WireGuard as initiator") + return e.configureAsInitiator(addr, presharedKey) } + e.log.Debugf("configure up WireGuard as responder") + return e.configureAsResponder(addr, presharedKey) +} + +func (e *EndpointUpdater) SwitchWGEndpoint(addr *net.UDPAddr, presharedKey *wgtypes.Key) error { + e.mu.Lock() + defer e.mu.Unlock() + // prevent to run new update while cancel the previous update e.waitForCloseTheDelayedUpdate() - var ctx context.Context - ctx, e.cancelFunc = context.WithCancel(context.Background()) - e.updateWg.Add(1) - go e.scheduleDelayedUpdate(ctx, addr, presharedKey) - - e.log.Debugf("configure up WireGuard and wait for handshake") - return e.updateWireGuardPeer(nil, presharedKey) + return e.updateWireGuardPeer(addr, presharedKey) } func (e *EndpointUpdater) RemoveWgPeer() error { @@ -66,6 +65,38 @@ func (e *EndpointUpdater) RemoveWgPeer() error { return e.wgConfig.WgInterface.RemovePeer(e.wgConfig.RemoteKey) } +func (e *EndpointUpdater) RemoveEndpointAddress() error { + e.mu.Lock() + defer e.mu.Unlock() + + e.waitForCloseTheDelayedUpdate() + return e.wgConfig.WgInterface.RemoveEndpointAddress(e.wgConfig.RemoteKey) +} + +func (e *EndpointUpdater) configureAsInitiator(addr *net.UDPAddr, presharedKey *wgtypes.Key) error { + if err := e.updateWireGuardPeer(addr, presharedKey); err != nil { + return err + } + return nil +} + +func (e *EndpointUpdater) configureAsResponder(addr *net.UDPAddr, presharedKey *wgtypes.Key) error { + // prevent to run new update while cancel the previous update + e.waitForCloseTheDelayedUpdate() + + e.log.Debugf("configure up WireGuard and wait for handshake") + var ctx context.Context + ctx, e.cancelFunc = context.WithCancel(context.Background()) + e.updateWg.Add(1) + go e.scheduleDelayedUpdate(ctx, addr, presharedKey) + + if err := e.updateWireGuardPeer(nil, presharedKey); err != nil { + e.waitForCloseTheDelayedUpdate() + return err + } + return nil +} + func (e *EndpointUpdater) waitForCloseTheDelayedUpdate() { if e.cancelFunc == nil { return @@ -101,3 +132,9 @@ func (e *EndpointUpdater) updateWireGuardPeer(endpoint *net.UDPAddr, presharedKe presharedKey, ) } + +// wgConfigWorkaround is a workaround for the issue with WireGuard configuration update +// When update a peer configuration in near to each other time, the second update can be ignored by WireGuard +func wgConfigWorkaround() { + time.Sleep(100 * time.Millisecond) +} diff --git a/client/internal/peer/ice/agent.go b/client/internal/peer/ice/agent.go index 79f68d279..c74b46d10 100644 --- a/client/internal/peer/ice/agent.go +++ b/client/internal/peer/ice/agent.go @@ -2,6 +2,7 @@ package ice import ( "context" + "fmt" "sync" "time" @@ -32,24 +33,6 @@ type ThreadSafeAgent struct { once sync.Once } -func (a *ThreadSafeAgent) Close() error { - var err error - a.once.Do(func() { - done := make(chan error, 1) - go func() { - done <- a.Agent.Close() - }() - - select { - case err = <-done: - case <-time.After(iceAgentCloseTimeout): - log.Warnf("ICE agent close timed out after %v, proceeding with cleanup", iceAgentCloseTimeout) - err = nil - } - }) - return err -} - func NewAgent(ctx context.Context, iFaceDiscover stdnet.ExternalIFaceDiscover, config Config, candidateTypes []ice.CandidateType, ufrag string, pwd string) (*ThreadSafeAgent, error) { iceKeepAlive := iceKeepAlive() iceDisconnectedTimeout := iceDisconnectedTimeout() @@ -93,9 +76,41 @@ func NewAgent(ctx context.Context, iFaceDiscover stdnet.ExternalIFaceDiscover, c return nil, err } + if agent == nil { + return nil, fmt.Errorf("ice.NewAgent returned nil agent without error") + } + return &ThreadSafeAgent{Agent: agent}, nil } +func (a *ThreadSafeAgent) Close() error { + var err error + a.once.Do(func() { + // Defensive check to prevent nil pointer dereference + // This can happen during sleep/wake transitions or memory corruption scenarios + // github.com/netbirdio/netbird/client/internal/peer/ice.(*ThreadSafeAgent).Close(0x40006883f0?) + // [signal 0xc0000005 code=0x0 addr=0x0 pc=0x7ff7e73af83c] + agent := a.Agent + if agent == nil { + log.Warnf("ICE agent is nil during close, skipping") + return + } + + done := make(chan error, 1) + go func() { + done <- agent.Close() + }() + + select { + case err = <-done: + case <-time.After(iceAgentCloseTimeout): + log.Warnf("ICE agent close timed out after %v, proceeding with cleanup", iceAgentCloseTimeout) + err = nil + } + }) + return err +} + func GenerateICECredentials() (string, string, error) { ufrag, err := randutil.GenerateCryptoRandomString(lenUFrag, runesAlpha) if err != nil { diff --git a/client/internal/peer/wg_watcher.go b/client/internal/peer/wg_watcher.go index d40ec7a80..799a9375e 100644 --- a/client/internal/peer/wg_watcher.go +++ b/client/internal/peer/wg_watcher.go @@ -32,6 +32,8 @@ type WGWatcher struct { enabled bool muEnabled sync.RWMutex + + resetCh chan struct{} } func NewWGWatcher(log *log.Entry, wgIfaceStater WGInterfaceStater, peerKey string, stateDump *stateDump) *WGWatcher { @@ -40,6 +42,7 @@ func NewWGWatcher(log *log.Entry, wgIfaceStater WGInterfaceStater, peerKey strin wgIfaceStater: wgIfaceStater, peerKey: peerKey, stateDump: stateDump, + resetCh: make(chan struct{}, 1), } } @@ -76,6 +79,15 @@ func (w *WGWatcher) IsEnabled() bool { return w.enabled } +// Reset signals the watcher that the WireGuard peer has been reset and a new +// handshake is expected. This restarts the handshake timeout from scratch. +func (w *WGWatcher) Reset() { + select { + case w.resetCh <- struct{}{}: + default: + } +} + // wgStateCheck help to check the state of the WireGuard handshake and relay connection func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn func(), enabledTime time.Time, initialHandshake time.Time) { w.log.Infof("WireGuard watcher started") @@ -105,6 +117,12 @@ func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn w.stateDump.WGcheckSuccess() w.log.Debugf("WireGuard watcher reset timer: %v", resetTime) + case <-w.resetCh: + w.log.Infof("WireGuard watcher received peer reset, restarting handshake timeout") + lastHandshake = time.Time{} + enabledTime = time.Now() + timer.Stop() + timer.Reset(wgHandshakeOvertime) case <-ctx.Done(): w.log.Infof("WireGuard watcher stopped") return diff --git a/client/internal/peer/worker_ice.go b/client/internal/peer/worker_ice.go index d0b21117d..4d07ade95 100644 --- a/client/internal/peer/worker_ice.go +++ b/client/internal/peer/worker_ice.go @@ -53,8 +53,9 @@ type WorkerICE struct { // increase by one when disconnecting the agent // with it the remote peer can discard the already deprecated offer/answer // Without it the remote peer may recreate a workable ICE connection - sessionID ICESessionID - muxAgent sync.Mutex + sessionID ICESessionID + remoteSessionChanged bool + muxAgent sync.Mutex localUfrag string localPwd string @@ -110,9 +111,12 @@ func (w *WorkerICE) OnNewOffer(remoteOfferAnswer *OfferAnswer) { return } w.log.Debugf("agent already exists, recreate the connection") + w.remoteSessionChanged = true w.agentDialerCancel() - if err := w.agent.Close(); err != nil { - w.log.Warnf("failed to close ICE agent: %s", err) + if w.agent != nil { + if err := w.agent.Close(); err != nil { + w.log.Warnf("failed to close ICE agent: %s", err) + } } sessionID, err := NewICESessionID() @@ -310,13 +314,17 @@ func (w *WorkerICE) connect(ctx context.Context, agent *icemaker.ThreadSafeAgent w.conn.onICEConnectionIsReady(selectedPriority(pair), ci) } -func (w *WorkerICE) closeAgent(agent *icemaker.ThreadSafeAgent, cancel context.CancelFunc) { +func (w *WorkerICE) closeAgent(agent *icemaker.ThreadSafeAgent, cancel context.CancelFunc) bool { cancel() if err := agent.Close(); err != nil { w.log.Warnf("failed to close ICE agent: %s", err) } w.muxAgent.Lock() + defer w.muxAgent.Unlock() + + sessionChanged := w.remoteSessionChanged + w.remoteSessionChanged = false if w.agent == agent { // consider to remove from here and move to the OnNewOffer @@ -329,7 +337,7 @@ func (w *WorkerICE) closeAgent(agent *icemaker.ThreadSafeAgent, cancel context.C w.agentConnecting = false w.remoteSessionID = "" } - w.muxAgent.Unlock() + return sessionChanged } func (w *WorkerICE) punchRemoteWGPort(pair *ice.CandidatePair, remoteWgPort int) { @@ -517,11 +525,11 @@ func (w *WorkerICE) onConnectionStateChange(agent *icemaker.ThreadSafeAgent, dia // 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 - w.closeAgent(agent, dialerCancel) + sessionChanged := w.closeAgent(agent, dialerCancel) if w.lastKnownState == ice.ConnectionStateConnected { w.lastKnownState = ice.ConnectionStateDisconnected - w.conn.onICEStateDisconnected() + w.conn.onICEStateDisconnected(sessionChanged) } default: return diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index f2fda84e0..b27f1932f 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -198,7 +198,7 @@ func getConfigDirForUser(username string) (string, error) { configDir := filepath.Join(DefaultConfigPathDir, username) if _, err := os.Stat(configDir); os.IsNotExist(err) { - if err := os.MkdirAll(configDir, 0600); err != nil { + if err := os.MkdirAll(configDir, 0700); err != nil { return "", err } } @@ -206,9 +206,15 @@ func getConfigDirForUser(username string) (string, error) { return configDir, nil } -func fileExists(path string) bool { +func fileExists(path string) (bool, error) { _, err := os.Stat(path) - return !os.IsNotExist(err) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err } // createNewConfig creates a new config generating a new Wireguard key and saving to file @@ -252,7 +258,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { } if config.AdminURL == nil { - log.Infof("using default Admin URL %s", DefaultManagementURL) + log.Infof("using default Admin URL %s", DefaultAdminURL) config.AdminURL, err = parseURL("Admin URL", DefaultAdminURL) if err != nil { return false, err @@ -635,7 +641,11 @@ func isPreSharedKeyHidden(preSharedKey *string) bool { // UpdateConfig update existing configuration according to input configuration and return with the configuration func UpdateConfig(input ConfigInput) (*Config, error) { - if !fileExists(input.ConfigPath) { + configExists, err := fileExists(input.ConfigPath) + if err != nil { + return nil, fmt.Errorf("failed to check if config file exists: %w", err) + } + if !configExists { return nil, fmt.Errorf("config file %s does not exist", input.ConfigPath) } @@ -644,7 +654,11 @@ func UpdateConfig(input ConfigInput) (*Config, error) { // UpdateOrCreateConfig reads existing config or generates a new one func UpdateOrCreateConfig(input ConfigInput) (*Config, error) { - if !fileExists(input.ConfigPath) { + configExists, err := fileExists(input.ConfigPath) + if err != nil { + return nil, fmt.Errorf("failed to check if config file exists: %w", err) + } + if !configExists { log.Infof("generating new config %s", input.ConfigPath) cfg, err := createNewConfig(input) if err != nil { @@ -657,7 +671,7 @@ func UpdateOrCreateConfig(input ConfigInput) (*Config, error) { if isPreSharedKeyHidden(input.PreSharedKey) { input.PreSharedKey = nil } - err := util.EnforcePermission(input.ConfigPath) + err = util.EnforcePermission(input.ConfigPath) if err != nil { log.Errorf("failed to enforce permission on config dir: %v", err) } @@ -784,7 +798,12 @@ func ReadConfig(configPath string) (*Config, error) { // ReadConfig read config file and return with Config. If it is not exists create a new with default values func readConfig(configPath string, createIfMissing bool) (*Config, error) { - if fileExists(configPath) { + configExists, err := fileExists(configPath) + if err != nil { + return nil, fmt.Errorf("failed to check if config file exists: %w", err) + } + + if configExists { err := util.EnforcePermission(configPath) if err != nil { log.Errorf("failed to enforce permission on config dir: %v", err) @@ -831,7 +850,11 @@ func DirectWriteOutConfig(path string, config *Config) error { // DirectUpdateOrCreateConfig is like UpdateOrCreateConfig but uses direct (non-atomic) writes. // Use this on platforms where atomic writes are blocked (e.g., tvOS sandbox). func DirectUpdateOrCreateConfig(input ConfigInput) (*Config, error) { - if !fileExists(input.ConfigPath) { + configExists, err := fileExists(input.ConfigPath) + if err != nil { + return nil, fmt.Errorf("failed to check if config file exists: %w", err) + } + if !configExists { log.Infof("generating new config %s", input.ConfigPath) cfg, err := createNewConfig(input) if err != nil { diff --git a/client/internal/profilemanager/service.go b/client/internal/profilemanager/service.go index bdb722c67..ef3eb1114 100644 --- a/client/internal/profilemanager/service.go +++ b/client/internal/profilemanager/service.go @@ -256,7 +256,11 @@ func (s *ServiceManager) AddProfile(profileName, username string) error { } profPath := filepath.Join(configDir, profileName+".json") - if fileExists(profPath) { + profileExists, err := fileExists(profPath) + if err != nil { + return fmt.Errorf("failed to check if profile exists: %w", err) + } + if profileExists { return ErrProfileAlreadyExists } @@ -285,7 +289,11 @@ func (s *ServiceManager) RemoveProfile(profileName, username string) error { return fmt.Errorf("cannot remove profile with reserved name: %s", defaultProfileName) } profPath := filepath.Join(configDir, profileName+".json") - if !fileExists(profPath) { + profileExists, err := fileExists(profPath) + if err != nil { + return fmt.Errorf("failed to check if profile exists: %w", err) + } + if !profileExists { return ErrProfileNotFound } diff --git a/client/internal/profilemanager/state.go b/client/internal/profilemanager/state.go index f84cb1032..f09391ede 100644 --- a/client/internal/profilemanager/state.go +++ b/client/internal/profilemanager/state.go @@ -20,7 +20,11 @@ func (pm *ProfileManager) GetProfileState(profileName string) (*ProfileState, er } stateFile := filepath.Join(configDir, profileName+".state.json") - if !fileExists(stateFile) { + stateFileExists, err := fileExists(stateFile) + if err != nil { + return nil, fmt.Errorf("failed to check if profile state file exists: %w", err) + } + if !stateFileExists { return nil, errors.New("profile state file does not exist") } diff --git a/client/internal/routemanager/client/client.go b/client/internal/routemanager/client/client.go index 0b8e161d2..bad616271 100644 --- a/client/internal/routemanager/client/client.go +++ b/client/internal/routemanager/client/client.go @@ -263,8 +263,14 @@ func (w *Watcher) watchPeerStatusChanges(ctx context.Context, peerKey string, pe case <-closer: return case routerStates := <-subscription.Events(): - peerStateUpdate <- routerStates - log.Debugf("triggered route state update for Peer: %s", peerKey) + select { + case peerStateUpdate <- routerStates: + log.Debugf("triggered route state update for Peer: %s", peerKey) + case <-ctx.Done(): + return + case <-closer: + return + } } } } diff --git a/client/internal/routemanager/dnsinterceptor/handler.go b/client/internal/routemanager/dnsinterceptor/handler.go index 12c9ff4af..4bf0d5476 100644 --- a/client/internal/routemanager/dnsinterceptor/handler.go +++ b/client/internal/routemanager/dnsinterceptor/handler.go @@ -351,6 +351,11 @@ func (d *DnsInterceptor) writeMsg(w dns.ResponseWriter, r *dns.Msg, logger *log. logger.Errorf("failed to update domain prefixes: %v", err) } + // Allow time for route changes to be applied before sending + // the DNS response (relevant on iOS where setTunnelNetworkSettings + // is asynchronous). + waitForRouteSettlement(logger) + d.replaceIPsInDNSResponse(r, newPrefixes, logger) } } diff --git a/client/internal/routemanager/dnsinterceptor/handler_ios.go b/client/internal/routemanager/dnsinterceptor/handler_ios.go new file mode 100644 index 000000000..4cf80eb16 --- /dev/null +++ b/client/internal/routemanager/dnsinterceptor/handler_ios.go @@ -0,0 +1,20 @@ +//go:build ios + +package dnsinterceptor + +import ( + "time" + + log "github.com/sirupsen/logrus" +) + +const routeSettleDelay = 500 * time.Millisecond + +// waitForRouteSettlement introduces a short delay on iOS to allow +// setTunnelNetworkSettings to apply route changes before the DNS +// response reaches the application. Without this, the first request +// to a newly resolved domain may bypass the tunnel. +func waitForRouteSettlement(logger *log.Entry) { + logger.Tracef("waiting %v for iOS route settlement", routeSettleDelay) + time.Sleep(routeSettleDelay) +} diff --git a/client/internal/routemanager/dnsinterceptor/handler_nonios.go b/client/internal/routemanager/dnsinterceptor/handler_nonios.go new file mode 100644 index 000000000..68cd7330b --- /dev/null +++ b/client/internal/routemanager/dnsinterceptor/handler_nonios.go @@ -0,0 +1,12 @@ +//go:build !ios + +package dnsinterceptor + +import log "github.com/sirupsen/logrus" + +func waitForRouteSettlement(_ *log.Entry) { + // No-op on non-iOS platforms: route changes are applied synchronously by + // the kernel, so no settlement delay is needed before the DNS response + // reaches the application. The delay is only required on iOS where + // setTunnelNetworkSettings applies routes asynchronously. +} diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go index 2baa0e668..9afe2049d 100644 --- a/client/internal/routemanager/manager.go +++ b/client/internal/routemanager/manager.go @@ -173,12 +173,21 @@ func (m *DefaultManager) setupAndroidRoutes(config ManagerConfig) { } func (m *DefaultManager) setupRefCounters(useNoop bool) { + var once sync.Once + var wgIface *net.Interface + toInterface := func() *net.Interface { + once.Do(func() { + wgIface = m.wgInterface.ToInterface() + }) + return wgIface + } + m.routeRefCounter = refcounter.New( func(prefix netip.Prefix, _ struct{}) (struct{}, error) { - return struct{}{}, m.sysOps.AddVPNRoute(prefix, m.wgInterface.ToInterface()) + return struct{}{}, m.sysOps.AddVPNRoute(prefix, toInterface()) }, func(prefix netip.Prefix, _ struct{}) error { - return m.sysOps.RemoveVPNRoute(prefix, m.wgInterface.ToInterface()) + return m.sysOps.RemoveVPNRoute(prefix, toInterface()) }, ) @@ -337,6 +346,23 @@ func (m *DefaultManager) updateSystemRoutes(newRoutes route.HAMap) error { } var merr *multierror.Error + + // Begin batch mode to avoid calling applyHostConfig() after each DNS handler operation + batchStarted := false + if m.dnsServer != nil { + m.dnsServer.BeginBatch() + batchStarted = true + defer func() { + if merr != nil { + // On error, cancel batch to discard partial DNS state + m.dnsServer.CancelBatch() + } else { + // On success, apply accumulated DNS changes + m.dnsServer.EndBatch() + } + }() + } + for id, handler := range toRemove { if err := handler.RemoveRoute(); err != nil { merr = multierror.Append(merr, fmt.Errorf("remove route %s: %w", handler.String(), err)) @@ -367,6 +393,7 @@ func (m *DefaultManager) updateSystemRoutes(newRoutes route.HAMap) error { m.activeRoutes[id] = handler } + _ = batchStarted // Mark as used return nberrors.FormatErrorOrNil(merr) } diff --git a/client/internal/routemanager/systemops/routeflags_bsd.go b/client/internal/routemanager/systemops/routeflags_bsd.go index ad32e5029..33280bfb3 100644 --- a/client/internal/routemanager/systemops/routeflags_bsd.go +++ b/client/internal/routemanager/systemops/routeflags_bsd.go @@ -4,16 +4,17 @@ package systemops import ( "strings" - "syscall" + + "golang.org/x/sys/unix" ) // filterRoutesByFlags returns true if the route message should be ignored based on its flags. func filterRoutesByFlags(routeMessageFlags int) bool { - if routeMessageFlags&syscall.RTF_UP == 0 { + if routeMessageFlags&unix.RTF_UP == 0 { return true } - if routeMessageFlags&(syscall.RTF_REJECT|syscall.RTF_BLACKHOLE|syscall.RTF_WASCLONED) != 0 { + if routeMessageFlags&(unix.RTF_REJECT|unix.RTF_BLACKHOLE|unix.RTF_WASCLONED) != 0 { return true } @@ -24,42 +25,51 @@ func filterRoutesByFlags(routeMessageFlags int) bool { func formatBSDFlags(flags int) string { var flagStrs []string - if flags&syscall.RTF_UP != 0 { + if flags&unix.RTF_UP != 0 { flagStrs = append(flagStrs, "U") } - if flags&syscall.RTF_GATEWAY != 0 { + if flags&unix.RTF_GATEWAY != 0 { flagStrs = append(flagStrs, "G") } - if flags&syscall.RTF_HOST != 0 { + if flags&unix.RTF_HOST != 0 { flagStrs = append(flagStrs, "H") } - if flags&syscall.RTF_REJECT != 0 { + if flags&unix.RTF_REJECT != 0 { flagStrs = append(flagStrs, "R") } - if flags&syscall.RTF_DYNAMIC != 0 { + if flags&unix.RTF_DYNAMIC != 0 { flagStrs = append(flagStrs, "D") } - if flags&syscall.RTF_MODIFIED != 0 { + if flags&unix.RTF_MODIFIED != 0 { flagStrs = append(flagStrs, "M") } - if flags&syscall.RTF_STATIC != 0 { + if flags&unix.RTF_STATIC != 0 { flagStrs = append(flagStrs, "S") } - if flags&syscall.RTF_LLINFO != 0 { + if flags&unix.RTF_LLINFO != 0 { flagStrs = append(flagStrs, "L") } - if flags&syscall.RTF_LOCAL != 0 { + if flags&unix.RTF_LOCAL != 0 { flagStrs = append(flagStrs, "l") } - if flags&syscall.RTF_BLACKHOLE != 0 { + if flags&unix.RTF_BLACKHOLE != 0 { flagStrs = append(flagStrs, "B") } - if flags&syscall.RTF_CLONING != 0 { + if flags&unix.RTF_CLONING != 0 { flagStrs = append(flagStrs, "C") } - if flags&syscall.RTF_WASCLONED != 0 { + if flags&unix.RTF_WASCLONED != 0 { flagStrs = append(flagStrs, "W") } + if flags&unix.RTF_PROTO1 != 0 { + flagStrs = append(flagStrs, "1") + } + if flags&unix.RTF_PROTO2 != 0 { + flagStrs = append(flagStrs, "2") + } + if flags&unix.RTF_PROTO3 != 0 { + flagStrs = append(flagStrs, "3") + } if len(flagStrs) == 0 { return "-" diff --git a/client/internal/routemanager/systemops/routeflags_freebsd.go b/client/internal/routemanager/systemops/routeflags_freebsd.go index 2338fe5d8..a8c82b3ed 100644 --- a/client/internal/routemanager/systemops/routeflags_freebsd.go +++ b/client/internal/routemanager/systemops/routeflags_freebsd.go @@ -4,17 +4,18 @@ package systemops import ( "strings" - "syscall" + + "golang.org/x/sys/unix" ) // filterRoutesByFlags returns true if the route message should be ignored based on its flags. func filterRoutesByFlags(routeMessageFlags int) bool { - if routeMessageFlags&syscall.RTF_UP == 0 { + if routeMessageFlags&unix.RTF_UP == 0 { return true } - // NOTE: syscall.RTF_WASCLONED deprecated in FreeBSD 8.0 - if routeMessageFlags&(syscall.RTF_REJECT|syscall.RTF_BLACKHOLE) != 0 { + // NOTE: RTF_WASCLONED deprecated in FreeBSD 8.0 + if routeMessageFlags&(unix.RTF_REJECT|unix.RTF_BLACKHOLE) != 0 { return true } @@ -25,37 +26,46 @@ func filterRoutesByFlags(routeMessageFlags int) bool { func formatBSDFlags(flags int) string { var flagStrs []string - if flags&syscall.RTF_UP != 0 { + if flags&unix.RTF_UP != 0 { flagStrs = append(flagStrs, "U") } - if flags&syscall.RTF_GATEWAY != 0 { + if flags&unix.RTF_GATEWAY != 0 { flagStrs = append(flagStrs, "G") } - if flags&syscall.RTF_HOST != 0 { + if flags&unix.RTF_HOST != 0 { flagStrs = append(flagStrs, "H") } - if flags&syscall.RTF_REJECT != 0 { + if flags&unix.RTF_REJECT != 0 { flagStrs = append(flagStrs, "R") } - if flags&syscall.RTF_DYNAMIC != 0 { + if flags&unix.RTF_DYNAMIC != 0 { flagStrs = append(flagStrs, "D") } - if flags&syscall.RTF_MODIFIED != 0 { + if flags&unix.RTF_MODIFIED != 0 { flagStrs = append(flagStrs, "M") } - if flags&syscall.RTF_STATIC != 0 { + if flags&unix.RTF_STATIC != 0 { flagStrs = append(flagStrs, "S") } - if flags&syscall.RTF_LLINFO != 0 { + if flags&unix.RTF_LLINFO != 0 { flagStrs = append(flagStrs, "L") } - if flags&syscall.RTF_LOCAL != 0 { + if flags&unix.RTF_LOCAL != 0 { flagStrs = append(flagStrs, "l") } - if flags&syscall.RTF_BLACKHOLE != 0 { + if flags&unix.RTF_BLACKHOLE != 0 { flagStrs = append(flagStrs, "B") } // Note: RTF_CLONING and RTF_WASCLONED deprecated in FreeBSD 8.0 + if flags&unix.RTF_PROTO1 != 0 { + flagStrs = append(flagStrs, "1") + } + if flags&unix.RTF_PROTO2 != 0 { + flagStrs = append(flagStrs, "2") + } + if flags&unix.RTF_PROTO3 != 0 { + flagStrs = append(flagStrs, "3") + } if len(flagStrs) == 0 { return "-" diff --git a/client/internal/sleep/handler/handler.go b/client/internal/sleep/handler/handler.go new file mode 100644 index 000000000..9c2c5d4d5 --- /dev/null +++ b/client/internal/sleep/handler/handler.go @@ -0,0 +1,80 @@ +package handler + +import ( + "context" + "sync" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal" +) + +type Agent interface { + Up(ctx context.Context) error + Down(ctx context.Context) error + Status() (internal.StatusType, error) +} + +type SleepHandler struct { + agent Agent + + mu sync.Mutex + // sleepTriggeredDown indicates whether the sleep handler triggered the last client down, to avoid unnecessary up on wake + sleepTriggeredDown bool +} + +func New(agent Agent) *SleepHandler { + return &SleepHandler{ + agent: agent, + } +} + +func (s *SleepHandler) HandleWakeUp(ctx context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + + if !s.sleepTriggeredDown { + log.Info("skipping up because wasn't sleep down") + return nil + } + + // avoid other wakeup runs if sleep didn't make the computer sleep + s.sleepTriggeredDown = false + + log.Info("running up after wake up") + err := s.agent.Up(ctx) + if err != nil { + log.Errorf("running up failed: %v", err) + return err + } + + log.Info("running up command executed successfully") + return nil +} + +func (s *SleepHandler) HandleSleep(ctx context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + + status, err := s.agent.Status() + if err != nil { + return err + } + + if status != internal.StatusConnecting && status != internal.StatusConnected { + log.Infof("skipping setting the agent down because status is %s", status) + return nil + } + + log.Info("running down after system started sleeping") + + if err = s.agent.Down(ctx); err != nil { + log.Errorf("running down failed: %v", err) + return err + } + + s.sleepTriggeredDown = true + + log.Info("running down executed successfully") + return nil +} diff --git a/client/internal/sleep/handler/handler_test.go b/client/internal/sleep/handler/handler_test.go new file mode 100644 index 000000000..9f79428fb --- /dev/null +++ b/client/internal/sleep/handler/handler_test.go @@ -0,0 +1,153 @@ +package handler + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal" +) + +type mockAgent struct { + upErr error + downErr error + statusErr error + status internal.StatusType + upCalls int +} + +func (m *mockAgent) Up(_ context.Context) error { + m.upCalls++ + return m.upErr +} + +func (m *mockAgent) Down(_ context.Context) error { + return m.downErr +} + +func (m *mockAgent) Status() (internal.StatusType, error) { + return m.status, m.statusErr +} + +func newHandler(status internal.StatusType) (*SleepHandler, *mockAgent) { + agent := &mockAgent{status: status} + return New(agent), agent +} + +func TestHandleWakeUp_SkipsWhenFlagFalse(t *testing.T) { + h, agent := newHandler(internal.StatusIdle) + + err := h.HandleWakeUp(context.Background()) + + require.NoError(t, err) + assert.Equal(t, 0, agent.upCalls, "Up should not be called when flag is false") +} + +func TestHandleWakeUp_ResetsFlagBeforeUp(t *testing.T) { + h, _ := newHandler(internal.StatusIdle) + h.sleepTriggeredDown = true + + // Even if Up fails, flag should be reset + _ = h.HandleWakeUp(context.Background()) + + assert.False(t, h.sleepTriggeredDown, "flag must be reset before calling Up") +} + +func TestHandleWakeUp_CallsUpWhenFlagSet(t *testing.T) { + h, agent := newHandler(internal.StatusIdle) + h.sleepTriggeredDown = true + + err := h.HandleWakeUp(context.Background()) + + require.NoError(t, err) + assert.Equal(t, 1, agent.upCalls) + assert.False(t, h.sleepTriggeredDown) +} + +func TestHandleWakeUp_ReturnsErrorFromUp(t *testing.T) { + h, agent := newHandler(internal.StatusIdle) + h.sleepTriggeredDown = true + agent.upErr = errors.New("up failed") + + err := h.HandleWakeUp(context.Background()) + + assert.ErrorIs(t, err, agent.upErr) + assert.False(t, h.sleepTriggeredDown, "flag should still be reset even when Up fails") +} + +func TestHandleWakeUp_SecondCallIsNoOp(t *testing.T) { + h, agent := newHandler(internal.StatusIdle) + h.sleepTriggeredDown = true + + _ = h.HandleWakeUp(context.Background()) + err := h.HandleWakeUp(context.Background()) + + require.NoError(t, err) + assert.Equal(t, 1, agent.upCalls, "second wakeup should be no-op") +} + +func TestHandleSleep_SkipsForNonActiveStates(t *testing.T) { + tests := []struct { + name string + status internal.StatusType + }{ + {"Idle", internal.StatusIdle}, + {"NeedsLogin", internal.StatusNeedsLogin}, + {"LoginFailed", internal.StatusLoginFailed}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h, _ := newHandler(tt.status) + + err := h.HandleSleep(context.Background()) + + require.NoError(t, err) + assert.False(t, h.sleepTriggeredDown) + }) + } +} + +func TestHandleSleep_ProceedsForActiveStates(t *testing.T) { + tests := []struct { + name string + status internal.StatusType + }{ + {"Connecting", internal.StatusConnecting}, + {"Connected", internal.StatusConnected}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h, _ := newHandler(tt.status) + + err := h.HandleSleep(context.Background()) + + require.NoError(t, err) + assert.True(t, h.sleepTriggeredDown) + }) + } +} + +func TestHandleSleep_ReturnsErrorFromStatus(t *testing.T) { + agent := &mockAgent{statusErr: errors.New("status error")} + h := New(agent) + + err := h.HandleSleep(context.Background()) + + assert.ErrorIs(t, err, agent.statusErr) + assert.False(t, h.sleepTriggeredDown) +} + +func TestHandleSleep_ReturnsErrorFromDown(t *testing.T) { + agent := &mockAgent{status: internal.StatusConnected, downErr: errors.New("down failed")} + h := New(agent) + + err := h.HandleSleep(context.Background()) + + assert.ErrorIs(t, err, agent.downErr) + assert.False(t, h.sleepTriggeredDown, "flag should not be set when Down fails") +} diff --git a/client/internal/updatemanager/manager_test.go b/client/internal/updatemanager/manager_test.go deleted file mode 100644 index 20ddec10d..000000000 --- a/client/internal/updatemanager/manager_test.go +++ /dev/null @@ -1,214 +0,0 @@ -//go:build windows || darwin - -package updatemanager - -import ( - "context" - "fmt" - "path" - "testing" - "time" - - v "github.com/hashicorp/go-version" - - "github.com/netbirdio/netbird/client/internal/peer" - "github.com/netbirdio/netbird/client/internal/statemanager" -) - -type versionUpdateMock struct { - latestVersion *v.Version - onUpdate func() -} - -func (v versionUpdateMock) StopWatch() {} - -func (v versionUpdateMock) SetDaemonVersion(newVersion string) bool { - return false -} - -func (v *versionUpdateMock) SetOnUpdateListener(updateFn func()) { - v.onUpdate = updateFn -} - -func (v versionUpdateMock) LatestVersion() *v.Version { - return v.latestVersion -} - -func (v versionUpdateMock) StartFetcher() {} - -func Test_LatestVersion(t *testing.T) { - testMatrix := []struct { - name string - daemonVersion string - initialLatestVersion *v.Version - latestVersion *v.Version - shouldUpdateInit bool - shouldUpdateLater bool - }{ - { - name: "Should only trigger update once due to time between triggers being < 5 Minutes", - daemonVersion: "1.0.0", - initialLatestVersion: v.Must(v.NewSemver("1.0.1")), - latestVersion: v.Must(v.NewSemver("1.0.2")), - shouldUpdateInit: true, - shouldUpdateLater: false, - }, - { - name: "Shouldn't update initially, but should update as soon as latest version is fetched", - daemonVersion: "1.0.0", - initialLatestVersion: nil, - latestVersion: v.Must(v.NewSemver("1.0.1")), - shouldUpdateInit: false, - shouldUpdateLater: true, - }, - } - - for idx, c := range testMatrix { - mockUpdate := &versionUpdateMock{latestVersion: c.initialLatestVersion} - tmpFile := path.Join(t.TempDir(), fmt.Sprintf("update-test-%d.json", idx)) - m, _ := newManager(peer.NewRecorder(""), statemanager.New(tmpFile)) - m.update = mockUpdate - - targetVersionChan := make(chan string, 1) - - m.triggerUpdateFn = func(ctx context.Context, targetVersion string) error { - targetVersionChan <- targetVersion - return nil - } - m.currentVersion = c.daemonVersion - m.Start(context.Background()) - m.SetVersion("latest") - var triggeredInit bool - select { - case targetVersion := <-targetVersionChan: - if targetVersion != c.initialLatestVersion.String() { - t.Errorf("%s: Initial update version mismatch, expected %v, got %v", c.name, c.initialLatestVersion.String(), targetVersion) - } - triggeredInit = true - case <-time.After(10 * time.Millisecond): - triggeredInit = false - } - if triggeredInit != c.shouldUpdateInit { - t.Errorf("%s: Initial update trigger mismatch, expected %v, got %v", c.name, c.shouldUpdateInit, triggeredInit) - } - - mockUpdate.latestVersion = c.latestVersion - mockUpdate.onUpdate() - - var triggeredLater bool - select { - case targetVersion := <-targetVersionChan: - if targetVersion != c.latestVersion.String() { - t.Errorf("%s: Update version mismatch, expected %v, got %v", c.name, c.latestVersion.String(), targetVersion) - } - triggeredLater = true - case <-time.After(10 * time.Millisecond): - triggeredLater = false - } - if triggeredLater != c.shouldUpdateLater { - t.Errorf("%s: Update trigger mismatch, expected %v, got %v", c.name, c.shouldUpdateLater, triggeredLater) - } - - m.Stop() - } -} - -func Test_HandleUpdate(t *testing.T) { - testMatrix := []struct { - name string - daemonVersion string - latestVersion *v.Version - expectedVersion string - shouldUpdate bool - }{ - { - name: "Update to a specific version should update regardless of if latestVersion is available yet", - daemonVersion: "0.55.0", - latestVersion: nil, - expectedVersion: "0.56.0", - shouldUpdate: true, - }, - { - name: "Update to specific version should not update if version matches", - daemonVersion: "0.55.0", - latestVersion: nil, - expectedVersion: "0.55.0", - shouldUpdate: false, - }, - { - name: "Update to specific version should not update if current version is newer", - daemonVersion: "0.55.0", - latestVersion: nil, - expectedVersion: "0.54.0", - shouldUpdate: false, - }, - { - name: "Update to latest version should update if latest is newer", - daemonVersion: "0.55.0", - latestVersion: v.Must(v.NewSemver("0.56.0")), - expectedVersion: "latest", - shouldUpdate: true, - }, - { - name: "Update to latest version should not update if latest == current", - daemonVersion: "0.56.0", - latestVersion: v.Must(v.NewSemver("0.56.0")), - expectedVersion: "latest", - shouldUpdate: false, - }, - { - name: "Should not update if daemon version is invalid", - daemonVersion: "development", - latestVersion: v.Must(v.NewSemver("1.0.0")), - expectedVersion: "latest", - shouldUpdate: false, - }, - { - name: "Should not update if expecting latest and latest version is unavailable", - daemonVersion: "0.55.0", - latestVersion: nil, - expectedVersion: "latest", - shouldUpdate: false, - }, - { - name: "Should not update if expected version is invalid", - daemonVersion: "0.55.0", - latestVersion: nil, - expectedVersion: "development", - shouldUpdate: false, - }, - } - for idx, c := range testMatrix { - tmpFile := path.Join(t.TempDir(), fmt.Sprintf("update-test-%d.json", idx)) - m, _ := newManager(peer.NewRecorder(""), statemanager.New(tmpFile)) - m.update = &versionUpdateMock{latestVersion: c.latestVersion} - targetVersionChan := make(chan string, 1) - - m.triggerUpdateFn = func(ctx context.Context, targetVersion string) error { - targetVersionChan <- targetVersion - return nil - } - - m.currentVersion = c.daemonVersion - m.Start(context.Background()) - m.SetVersion(c.expectedVersion) - - var updateTriggered bool - select { - case targetVersion := <-targetVersionChan: - if c.expectedVersion == "latest" && targetVersion != c.latestVersion.String() { - t.Errorf("%s: Update version mismatch, expected %v, got %v", c.name, c.latestVersion.String(), targetVersion) - } else if c.expectedVersion != "latest" && targetVersion != c.expectedVersion { - t.Errorf("%s: Update version mismatch, expected %v, got %v", c.name, c.expectedVersion, targetVersion) - } - updateTriggered = true - case <-time.After(10 * time.Millisecond): - updateTriggered = false - } - - if updateTriggered != c.shouldUpdate { - t.Errorf("%s: Update trigger mismatch, expected %v, got %v", c.name, c.shouldUpdate, updateTriggered) - } - m.Stop() - } -} diff --git a/client/internal/updatemanager/manager_unsupported.go b/client/internal/updatemanager/manager_unsupported.go deleted file mode 100644 index 4e87c2d77..000000000 --- a/client/internal/updatemanager/manager_unsupported.go +++ /dev/null @@ -1,39 +0,0 @@ -//go:build !windows && !darwin - -package updatemanager - -import ( - "context" - "fmt" - - "github.com/netbirdio/netbird/client/internal/peer" - "github.com/netbirdio/netbird/client/internal/statemanager" -) - -// Manager is a no-op stub for unsupported platforms -type Manager struct{} - -// NewManager returns a no-op manager for unsupported platforms -func NewManager(statusRecorder *peer.Status, stateManager *statemanager.Manager) (*Manager, error) { - return nil, fmt.Errorf("update manager is not supported on this platform") -} - -// CheckUpdateSuccess is a no-op on unsupported platforms -func (m *Manager) CheckUpdateSuccess(ctx context.Context) { - // no-op -} - -// Start is a no-op on unsupported platforms -func (m *Manager) Start(ctx context.Context) { - // no-op -} - -// SetVersion is a no-op on unsupported platforms -func (m *Manager) SetVersion(expectedVersion string) { - // no-op -} - -// Stop is a no-op on unsupported platforms -func (m *Manager) Stop() { - // no-op -} diff --git a/client/internal/updatemanager/doc.go b/client/internal/updater/doc.go similarity index 93% rename from client/internal/updatemanager/doc.go rename to client/internal/updater/doc.go index 54d1bdeab..e1924aa43 100644 --- a/client/internal/updatemanager/doc.go +++ b/client/internal/updater/doc.go @@ -1,4 +1,4 @@ -// Package updatemanager provides automatic update management for the NetBird client. +// Package updater provides automatic update management for the NetBird client. // It monitors for new versions, handles update triggers from management server directives, // and orchestrates the download and installation of client updates. // @@ -32,4 +32,4 @@ // // This enables verification of successful updates and appropriate user notification // after the client restarts with the new version. -package updatemanager +package updater diff --git a/client/internal/updatemanager/downloader/downloader.go b/client/internal/updater/downloader/downloader.go similarity index 100% rename from client/internal/updatemanager/downloader/downloader.go rename to client/internal/updater/downloader/downloader.go diff --git a/client/internal/updatemanager/downloader/downloader_test.go b/client/internal/updater/downloader/downloader_test.go similarity index 100% rename from client/internal/updatemanager/downloader/downloader_test.go rename to client/internal/updater/downloader/downloader_test.go diff --git a/client/internal/updatemanager/installer/binary_nowindows.go b/client/internal/updater/installer/binary_nowindows.go similarity index 100% rename from client/internal/updatemanager/installer/binary_nowindows.go rename to client/internal/updater/installer/binary_nowindows.go diff --git a/client/internal/updatemanager/installer/binary_windows.go b/client/internal/updater/installer/binary_windows.go similarity index 100% rename from client/internal/updatemanager/installer/binary_windows.go rename to client/internal/updater/installer/binary_windows.go diff --git a/client/internal/updatemanager/installer/doc.go b/client/internal/updater/installer/doc.go similarity index 100% rename from client/internal/updatemanager/installer/doc.go rename to client/internal/updater/installer/doc.go diff --git a/client/internal/updatemanager/installer/installer.go b/client/internal/updater/installer/installer.go similarity index 100% rename from client/internal/updatemanager/installer/installer.go rename to client/internal/updater/installer/installer.go diff --git a/client/internal/updatemanager/installer/installer_common.go b/client/internal/updater/installer/installer_common.go similarity index 97% rename from client/internal/updatemanager/installer/installer_common.go rename to client/internal/updater/installer/installer_common.go index 03378d55f..8e44bee82 100644 --- a/client/internal/updatemanager/installer/installer_common.go +++ b/client/internal/updater/installer/installer_common.go @@ -16,8 +16,8 @@ import ( goversion "github.com/hashicorp/go-version" log "github.com/sirupsen/logrus" - "github.com/netbirdio/netbird/client/internal/updatemanager/downloader" - "github.com/netbirdio/netbird/client/internal/updatemanager/reposign" + "github.com/netbirdio/netbird/client/internal/updater/downloader" + "github.com/netbirdio/netbird/client/internal/updater/reposign" ) type Installer struct { diff --git a/client/internal/updatemanager/installer/installer_log_darwin.go b/client/internal/updater/installer/installer_log_darwin.go similarity index 100% rename from client/internal/updatemanager/installer/installer_log_darwin.go rename to client/internal/updater/installer/installer_log_darwin.go diff --git a/client/internal/updatemanager/installer/installer_log_windows.go b/client/internal/updater/installer/installer_log_windows.go similarity index 100% rename from client/internal/updatemanager/installer/installer_log_windows.go rename to client/internal/updater/installer/installer_log_windows.go diff --git a/client/internal/updatemanager/installer/installer_run_darwin.go b/client/internal/updater/installer/installer_run_darwin.go similarity index 100% rename from client/internal/updatemanager/installer/installer_run_darwin.go rename to client/internal/updater/installer/installer_run_darwin.go diff --git a/client/internal/updatemanager/installer/installer_run_windows.go b/client/internal/updater/installer/installer_run_windows.go similarity index 100% rename from client/internal/updatemanager/installer/installer_run_windows.go rename to client/internal/updater/installer/installer_run_windows.go diff --git a/client/internal/updatemanager/installer/log.go b/client/internal/updater/installer/log.go similarity index 100% rename from client/internal/updatemanager/installer/log.go rename to client/internal/updater/installer/log.go diff --git a/client/internal/updatemanager/installer/procattr_darwin.go b/client/internal/updater/installer/procattr_darwin.go similarity index 100% rename from client/internal/updatemanager/installer/procattr_darwin.go rename to client/internal/updater/installer/procattr_darwin.go diff --git a/client/internal/updatemanager/installer/procattr_windows.go b/client/internal/updater/installer/procattr_windows.go similarity index 100% rename from client/internal/updatemanager/installer/procattr_windows.go rename to client/internal/updater/installer/procattr_windows.go diff --git a/client/internal/updatemanager/installer/repourl_dev.go b/client/internal/updater/installer/repourl_dev.go similarity index 100% rename from client/internal/updatemanager/installer/repourl_dev.go rename to client/internal/updater/installer/repourl_dev.go diff --git a/client/internal/updatemanager/installer/repourl_prod.go b/client/internal/updater/installer/repourl_prod.go similarity index 100% rename from client/internal/updatemanager/installer/repourl_prod.go rename to client/internal/updater/installer/repourl_prod.go diff --git a/client/internal/updatemanager/installer/result.go b/client/internal/updater/installer/result.go similarity index 98% rename from client/internal/updatemanager/installer/result.go rename to client/internal/updater/installer/result.go index 03d08d527..526c3eb53 100644 --- a/client/internal/updatemanager/installer/result.go +++ b/client/internal/updater/installer/result.go @@ -203,7 +203,10 @@ func (rh *ResultHandler) write(result Result) error { func (rh *ResultHandler) cleanup() error { err := os.Remove(rh.resultFile) - if err != nil && !os.IsNotExist(err) { + if err != nil { + if os.IsNotExist(err) { + return nil + } return err } log.Debugf("delete installer result file: %s", rh.resultFile) diff --git a/client/internal/updatemanager/installer/types.go b/client/internal/updater/installer/types.go similarity index 100% rename from client/internal/updatemanager/installer/types.go rename to client/internal/updater/installer/types.go diff --git a/client/internal/updatemanager/installer/types_darwin.go b/client/internal/updater/installer/types_darwin.go similarity index 100% rename from client/internal/updatemanager/installer/types_darwin.go rename to client/internal/updater/installer/types_darwin.go diff --git a/client/internal/updatemanager/installer/types_windows.go b/client/internal/updater/installer/types_windows.go similarity index 100% rename from client/internal/updatemanager/installer/types_windows.go rename to client/internal/updater/installer/types_windows.go diff --git a/client/internal/updatemanager/manager.go b/client/internal/updater/manager.go similarity index 52% rename from client/internal/updatemanager/manager.go rename to client/internal/updater/manager.go index eae11de56..dfcb93177 100644 --- a/client/internal/updatemanager/manager.go +++ b/client/internal/updater/manager.go @@ -1,12 +1,9 @@ -//go:build windows || darwin - -package updatemanager +package updater import ( "context" "errors" "fmt" - "runtime" "sync" "time" @@ -15,7 +12,7 @@ import ( "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/statemanager" - "github.com/netbirdio/netbird/client/internal/updatemanager/installer" + "github.com/netbirdio/netbird/client/internal/updater/installer" cProto "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/version" ) @@ -41,6 +38,9 @@ type Manager struct { statusRecorder *peer.Status stateManager *statemanager.Manager + downloadOnly bool // true when no enforcement from management; notifies UI to download latest + forceUpdate bool // true when management sets AlwaysUpdate; skips UI interaction and installs directly + lastTrigger time.Time mgmUpdateChan chan struct{} updateChannel chan struct{} @@ -53,37 +53,38 @@ type Manager struct { expectedVersion *v.Version updateToLatestVersion bool - // updateMutex protect update and expectedVersion fields + pendingVersion *v.Version + + // updateMutex protects update, expectedVersion, updateToLatestVersion, + // downloadOnly, forceUpdate, pendingVersion, and lastTrigger fields updateMutex sync.Mutex - triggerUpdateFn func(context.Context, string) error + // installMutex and installing guard against concurrent installation attempts + installMutex sync.Mutex + installing bool + + // protect to start the service multiple times + mu sync.Mutex + + autoUpdateSupported func() bool } -func NewManager(statusRecorder *peer.Status, stateManager *statemanager.Manager) (*Manager, error) { - if runtime.GOOS == "darwin" { - isBrew := !installer.TypeOfInstaller(context.Background()).Downloadable() - if isBrew { - log.Warnf("auto-update disabled on Home Brew installation") - return nil, fmt.Errorf("auto-update not supported on Home Brew installation yet") - } - } - return newManager(statusRecorder, stateManager) -} - -func newManager(statusRecorder *peer.Status, stateManager *statemanager.Manager) (*Manager, error) { +// NewManager creates a new update manager. The manager is single-use: once Stop() is called, it cannot be restarted. +func NewManager(statusRecorder *peer.Status, stateManager *statemanager.Manager) *Manager { manager := &Manager{ - statusRecorder: statusRecorder, - stateManager: stateManager, - mgmUpdateChan: make(chan struct{}, 1), - updateChannel: make(chan struct{}, 1), - currentVersion: version.NetbirdVersion(), - update: version.NewUpdate("nb/client"), + statusRecorder: statusRecorder, + stateManager: stateManager, + mgmUpdateChan: make(chan struct{}, 1), + updateChannel: make(chan struct{}, 1), + currentVersion: version.NetbirdVersion(), + update: version.NewUpdate("nb/client"), + downloadOnly: true, + autoUpdateSupported: isAutoUpdateSupported, } - manager.triggerUpdateFn = manager.triggerUpdate stateManager.RegisterState(&UpdateState{}) - return manager, nil + return manager } // CheckUpdateSuccess checks if the update was successful and send a notification. @@ -124,8 +125,10 @@ func (m *Manager) CheckUpdateSuccess(ctx context.Context) { } func (m *Manager) Start(ctx context.Context) { + log.Infof("starting update manager") + m.mu.Lock() + defer m.mu.Unlock() if m.cancel != nil { - log.Errorf("Manager already started") return } @@ -142,13 +145,32 @@ func (m *Manager) Start(ctx context.Context) { m.cancel = cancel m.wg.Add(1) - go m.updateLoop(ctx) + go func() { + defer m.wg.Done() + m.updateLoop(ctx) + }() } -func (m *Manager) SetVersion(expectedVersion string) { - log.Infof("set expected agent version for upgrade: %s", expectedVersion) - if m.cancel == nil { - log.Errorf("manager not started") +func (m *Manager) SetDownloadOnly() { + m.updateMutex.Lock() + m.downloadOnly = true + m.forceUpdate = false + m.expectedVersion = nil + m.updateToLatestVersion = false + m.lastTrigger = time.Time{} + m.updateMutex.Unlock() + + select { + case m.mgmUpdateChan <- struct{}{}: + default: + } +} + +func (m *Manager) SetVersion(expectedVersion string, forceUpdate bool) { + log.Infof("expected version changed to %s, force update: %t", expectedVersion, forceUpdate) + + if !m.autoUpdateSupported() { + log.Warnf("auto-update not supported on this platform") return } @@ -159,6 +181,7 @@ func (m *Manager) SetVersion(expectedVersion string) { log.Errorf("empty expected version provided") m.expectedVersion = nil m.updateToLatestVersion = false + m.downloadOnly = true return } @@ -178,12 +201,97 @@ func (m *Manager) SetVersion(expectedVersion string) { m.updateToLatestVersion = false } + m.lastTrigger = time.Time{} + m.downloadOnly = false + m.forceUpdate = forceUpdate + select { case m.mgmUpdateChan <- struct{}{}: default: } } +// Install triggers the installation of the pending version. It is called when the user clicks the install button in the UI. +func (m *Manager) Install(ctx context.Context) error { + if !m.autoUpdateSupported() { + return fmt.Errorf("auto-update not supported on this platform") + } + + m.updateMutex.Lock() + pending := m.pendingVersion + m.updateMutex.Unlock() + + if pending == nil { + return fmt.Errorf("no pending version to install") + } + + return m.tryInstall(ctx, pending) +} + +// tryInstall ensures only one installation runs at a time. Concurrent callers +// receive an error immediately rather than queuing behind a running install. +func (m *Manager) tryInstall(ctx context.Context, targetVersion *v.Version) error { + m.installMutex.Lock() + if m.installing { + m.installMutex.Unlock() + return fmt.Errorf("installation already in progress") + } + m.installing = true + m.installMutex.Unlock() + + defer func() { + m.installMutex.Lock() + m.installing = false + m.installMutex.Unlock() + }() + + return m.install(ctx, targetVersion) +} + +// NotifyUI re-publishes the current update state to a newly connected UI client. +// Only needed for download-only mode where the latest version is already cached +// NotifyUI re-publishes the current update state so a newly connected UI gets the info. +func (m *Manager) NotifyUI() { + m.updateMutex.Lock() + if m.update == nil { + m.updateMutex.Unlock() + return + } + downloadOnly := m.downloadOnly + pendingVersion := m.pendingVersion + latestVersion := m.update.LatestVersion() + m.updateMutex.Unlock() + + if downloadOnly { + if latestVersion == nil { + return + } + currentVersion, err := v.NewVersion(m.currentVersion) + if err != nil || currentVersion.GreaterThanOrEqual(latestVersion) { + return + } + m.statusRecorder.PublishEvent( + cProto.SystemEvent_INFO, + cProto.SystemEvent_SYSTEM, + "New version available", + "", + map[string]string{"new_version_available": latestVersion.String()}, + ) + return + } + + if pendingVersion != nil { + m.statusRecorder.PublishEvent( + cProto.SystemEvent_INFO, + cProto.SystemEvent_SYSTEM, + "New version available", + "", + map[string]string{"new_version_available": pendingVersion.String(), "enforced": "true"}, + ) + } +} + +// Stop is not used at the moment because it fully depends on the daemon. In a future refactor it may make sense to use it. func (m *Manager) Stop() { if m.cancel == nil { return @@ -214,8 +322,6 @@ func (m *Manager) onContextCancel() { } func (m *Manager) updateLoop(ctx context.Context) { - defer m.wg.Done() - for { select { case <-ctx.Done(): @@ -239,55 +345,89 @@ func (m *Manager) handleUpdate(ctx context.Context) { return } - expectedVersion := m.expectedVersion - useLatest := m.updateToLatestVersion + downloadOnly := m.downloadOnly + forceUpdate := m.forceUpdate curLatestVersion := m.update.LatestVersion() - m.updateMutex.Unlock() switch { - // Resolve "latest" to actual version - case useLatest: + // Download-only mode or resolve "latest" to actual version + case downloadOnly, m.updateToLatestVersion: if curLatestVersion == nil { log.Tracef("latest version not fetched yet") + m.updateMutex.Unlock() return } updateVersion = curLatestVersion - // Update to specific version - case expectedVersion != nil: - updateVersion = expectedVersion + // Install to specific version + case m.expectedVersion != nil: + updateVersion = m.expectedVersion default: log.Debugf("no expected version information set") + m.updateMutex.Unlock() return } log.Debugf("checking update option, current version: %s, target version: %s", m.currentVersion, updateVersion) - if !m.shouldUpdate(updateVersion) { + if !m.shouldUpdate(updateVersion, forceUpdate) { + m.updateMutex.Unlock() return } m.lastTrigger = time.Now() - log.Infof("Auto-update triggered, current version: %s, target version: %s", m.currentVersion, updateVersion) - m.statusRecorder.PublishEvent( - cProto.SystemEvent_CRITICAL, - cProto.SystemEvent_SYSTEM, - "Automatically updating client", - "Your client version is older than auto-update version set in Management, updating client now.", - nil, - ) + log.Infof("new version available: %s", updateVersion) + + if !downloadOnly && !forceUpdate { + m.pendingVersion = updateVersion + } + m.updateMutex.Unlock() + + if downloadOnly { + m.statusRecorder.PublishEvent( + cProto.SystemEvent_INFO, + cProto.SystemEvent_SYSTEM, + "New version available", + "", + map[string]string{"new_version_available": updateVersion.String()}, + ) + return + } + + if forceUpdate { + if err := m.tryInstall(ctx, updateVersion); err != nil { + log.Errorf("force update failed: %v", err) + } + return + } + m.statusRecorder.PublishEvent( + cProto.SystemEvent_INFO, + cProto.SystemEvent_SYSTEM, + "New version available", + "", + map[string]string{"new_version_available": updateVersion.String(), "enforced": "true"}, + ) +} + +func (m *Manager) install(ctx context.Context, pendingVersion *v.Version) error { + m.statusRecorder.PublishEvent( + cProto.SystemEvent_CRITICAL, + cProto.SystemEvent_SYSTEM, + "Updating client", + "Installing update now.", + nil, + ) m.statusRecorder.PublishEvent( cProto.SystemEvent_CRITICAL, cProto.SystemEvent_SYSTEM, "", "", - map[string]string{"progress_window": "show", "version": updateVersion.String()}, + map[string]string{"progress_window": "show", "version": pendingVersion.String()}, ) updateState := UpdateState{ PreUpdateVersion: m.currentVersion, - TargetVersion: updateVersion.String(), + TargetVersion: pendingVersion.String(), } - if err := m.stateManager.UpdateState(updateState); err != nil { log.Warnf("failed to update state: %v", err) } else { @@ -296,8 +436,9 @@ func (m *Manager) handleUpdate(ctx context.Context) { } } - if err := m.triggerUpdateFn(ctx, updateVersion.String()); err != nil { - log.Errorf("Error triggering auto-update: %v", err) + inst := installer.New() + if err := inst.RunInstallation(ctx, pendingVersion.String()); err != nil { + log.Errorf("error triggering update: %v", err) m.statusRecorder.PublishEvent( cProto.SystemEvent_ERROR, cProto.SystemEvent_SYSTEM, @@ -305,7 +446,9 @@ func (m *Manager) handleUpdate(ctx context.Context) { fmt.Sprintf("Auto-update failed: %v", err), nil, ) + return err } + return nil } // loadAndDeleteUpdateState loads the update state, deletes it from storage, and returns it. @@ -339,7 +482,7 @@ func (m *Manager) loadAndDeleteUpdateState(ctx context.Context) (*UpdateState, e return updateState, nil } -func (m *Manager) shouldUpdate(updateVersion *v.Version) bool { +func (m *Manager) shouldUpdate(updateVersion *v.Version, forceUpdate bool) bool { if m.currentVersion == developmentVersion { log.Debugf("skipping auto-update, running development version") return false @@ -354,8 +497,8 @@ func (m *Manager) shouldUpdate(updateVersion *v.Version) bool { return false } - if time.Since(m.lastTrigger) < 5*time.Minute { - log.Debugf("skipping auto-update, last update was %s ago", time.Since(m.lastTrigger)) + if forceUpdate && time.Since(m.lastTrigger) < 3*time.Minute { + log.Infof("skipping auto-update, last update was %s ago", time.Since(m.lastTrigger)) return false } @@ -367,8 +510,3 @@ func (m *Manager) lastResultErrReason() string { result := installer.NewResultHandler(inst.TempDir()) return result.GetErrorResultReason() } - -func (m *Manager) triggerUpdate(ctx context.Context, targetVersion string) error { - inst := installer.New() - return inst.RunInstallation(ctx, targetVersion) -} diff --git a/client/internal/updater/manager_linux_test.go b/client/internal/updater/manager_linux_test.go new file mode 100644 index 000000000..b05dd7e7d --- /dev/null +++ b/client/internal/updater/manager_linux_test.go @@ -0,0 +1,111 @@ +//go:build !windows && !darwin + +package updater + +import ( + "context" + "fmt" + "path" + "testing" + "time" + + v "github.com/hashicorp/go-version" + + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/statemanager" +) + +// On Linux, only Mode 1 (downloadOnly) is supported. +// SetVersion is a no-op because auto-update installation is not supported. + +func Test_LatestVersion_Linux(t *testing.T) { + testMatrix := []struct { + name string + daemonVersion string + initialLatestVersion *v.Version + latestVersion *v.Version + shouldUpdateInit bool + shouldUpdateLater bool + }{ + { + name: "Should notify again when a newer version arrives even within 5 minutes", + daemonVersion: "1.0.0", + initialLatestVersion: v.Must(v.NewSemver("1.0.1")), + latestVersion: v.Must(v.NewSemver("1.0.2")), + shouldUpdateInit: true, + shouldUpdateLater: true, + }, + { + name: "Shouldn't notify initially, but should notify as soon as latest version is fetched", + daemonVersion: "1.0.0", + initialLatestVersion: nil, + latestVersion: v.Must(v.NewSemver("1.0.1")), + shouldUpdateInit: false, + shouldUpdateLater: true, + }, + } + + for idx, c := range testMatrix { + mockUpdate := &versionUpdateMock{latestVersion: c.initialLatestVersion} + tmpFile := path.Join(t.TempDir(), fmt.Sprintf("update-test-%d.json", idx)) + recorder := peer.NewRecorder("") + sub := recorder.SubscribeToEvents() + defer recorder.UnsubscribeFromEvents(sub) + + m := NewManager(recorder, statemanager.New(tmpFile)) + m.update = mockUpdate + m.currentVersion = c.daemonVersion + m.Start(context.Background()) + m.SetDownloadOnly() + + ver, enforced := waitForUpdateEvent(sub, 500*time.Millisecond) + triggeredInit := ver != "" + if enforced { + t.Errorf("%s: Linux Mode 1 must never have enforced metadata", c.name) + } + if triggeredInit != c.shouldUpdateInit { + t.Errorf("%s: Initial notify mismatch, expected %v, got %v", c.name, c.shouldUpdateInit, triggeredInit) + } + if triggeredInit && c.initialLatestVersion != nil && ver != c.initialLatestVersion.String() { + t.Errorf("%s: Initial version mismatch, expected %v, got %v", c.name, c.initialLatestVersion.String(), ver) + } + + mockUpdate.latestVersion = c.latestVersion + mockUpdate.onUpdate() + + ver, enforced = waitForUpdateEvent(sub, 500*time.Millisecond) + triggeredLater := ver != "" + if enforced { + t.Errorf("%s: Linux Mode 1 must never have enforced metadata", c.name) + } + if triggeredLater != c.shouldUpdateLater { + t.Errorf("%s: Later notify mismatch, expected %v, got %v", c.name, c.shouldUpdateLater, triggeredLater) + } + if triggeredLater && c.latestVersion != nil && ver != c.latestVersion.String() { + t.Errorf("%s: Later version mismatch, expected %v, got %v", c.name, c.latestVersion.String(), ver) + } + + m.Stop() + } +} + +func Test_SetVersion_NoOp_Linux(t *testing.T) { + // On Linux, SetVersion should be a no-op — no events fired + tmpFile := path.Join(t.TempDir(), "update-test-noop.json") + recorder := peer.NewRecorder("") + sub := recorder.SubscribeToEvents() + defer recorder.UnsubscribeFromEvents(sub) + + m := NewManager(recorder, statemanager.New(tmpFile)) + m.update = &versionUpdateMock{latestVersion: v.Must(v.NewSemver("1.0.1"))} + m.currentVersion = "1.0.0" + m.Start(context.Background()) + m.SetVersion("1.0.1", false) + + ver, _ := waitForUpdateEvent(sub, 500*time.Millisecond) + if ver != "" { + t.Errorf("SetVersion should be a no-op on Linux, but got event with version %s", ver) + } + + m.Stop() +} diff --git a/client/internal/updater/manager_test.go b/client/internal/updater/manager_test.go new file mode 100644 index 000000000..107dca2b3 --- /dev/null +++ b/client/internal/updater/manager_test.go @@ -0,0 +1,227 @@ +//go:build windows || darwin + +package updater + +import ( + "context" + "fmt" + "path" + "testing" + "time" + + v "github.com/hashicorp/go-version" + + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/statemanager" + cProto "github.com/netbirdio/netbird/client/proto" +) + +func Test_LatestVersion(t *testing.T) { + testMatrix := []struct { + name string + daemonVersion string + initialLatestVersion *v.Version + latestVersion *v.Version + shouldUpdateInit bool + shouldUpdateLater bool + }{ + { + name: "Should notify again when a newer version arrives even within 5 minutes", + daemonVersion: "1.0.0", + initialLatestVersion: v.Must(v.NewSemver("1.0.1")), + latestVersion: v.Must(v.NewSemver("1.0.2")), + shouldUpdateInit: true, + shouldUpdateLater: true, + }, + { + name: "Shouldn't update initially, but should update as soon as latest version is fetched", + daemonVersion: "1.0.0", + initialLatestVersion: nil, + latestVersion: v.Must(v.NewSemver("1.0.1")), + shouldUpdateInit: false, + shouldUpdateLater: true, + }, + } + + for idx, c := range testMatrix { + mockUpdate := &versionUpdateMock{latestVersion: c.initialLatestVersion} + tmpFile := path.Join(t.TempDir(), fmt.Sprintf("update-test-%d.json", idx)) + recorder := peer.NewRecorder("") + sub := recorder.SubscribeToEvents() + defer recorder.UnsubscribeFromEvents(sub) + + m := NewManager(recorder, statemanager.New(tmpFile)) + m.update = mockUpdate + m.currentVersion = c.daemonVersion + m.autoUpdateSupported = func() bool { return true } + m.Start(context.Background()) + m.SetVersion("latest", false) + + ver, _ := waitForUpdateEvent(sub, 500*time.Millisecond) + triggeredInit := ver != "" + if triggeredInit != c.shouldUpdateInit { + t.Errorf("%s: Initial update trigger mismatch, expected %v, got %v", c.name, c.shouldUpdateInit, triggeredInit) + } + if triggeredInit && c.initialLatestVersion != nil && ver != c.initialLatestVersion.String() { + t.Errorf("%s: Initial update version mismatch, expected %v, got %v", c.name, c.initialLatestVersion.String(), ver) + } + + mockUpdate.latestVersion = c.latestVersion + mockUpdate.onUpdate() + + ver, _ = waitForUpdateEvent(sub, 500*time.Millisecond) + triggeredLater := ver != "" + if triggeredLater != c.shouldUpdateLater { + t.Errorf("%s: Later update trigger mismatch, expected %v, got %v", c.name, c.shouldUpdateLater, triggeredLater) + } + if triggeredLater && c.latestVersion != nil && ver != c.latestVersion.String() { + t.Errorf("%s: Later update version mismatch, expected %v, got %v", c.name, c.latestVersion.String(), ver) + } + + m.Stop() + } +} + +func Test_HandleUpdate(t *testing.T) { + testMatrix := []struct { + name string + daemonVersion string + latestVersion *v.Version + expectedVersion string + shouldUpdate bool + }{ + { + name: "Install to a specific version should update regardless of if latestVersion is available yet", + daemonVersion: "0.55.0", + latestVersion: nil, + expectedVersion: "0.56.0", + shouldUpdate: true, + }, + { + name: "Install to specific version should not update if version matches", + daemonVersion: "0.55.0", + latestVersion: nil, + expectedVersion: "0.55.0", + shouldUpdate: false, + }, + { + name: "Install to specific version should not update if current version is newer", + daemonVersion: "0.55.0", + latestVersion: nil, + expectedVersion: "0.54.0", + shouldUpdate: false, + }, + { + name: "Install to latest version should update if latest is newer", + daemonVersion: "0.55.0", + latestVersion: v.Must(v.NewSemver("0.56.0")), + expectedVersion: "latest", + shouldUpdate: true, + }, + { + name: "Install to latest version should not update if latest == current", + daemonVersion: "0.56.0", + latestVersion: v.Must(v.NewSemver("0.56.0")), + expectedVersion: "latest", + shouldUpdate: false, + }, + { + name: "Should not update if daemon version is invalid", + daemonVersion: "development", + latestVersion: v.Must(v.NewSemver("1.0.0")), + expectedVersion: "latest", + shouldUpdate: false, + }, + { + name: "Should not update if expecting latest and latest version is unavailable", + daemonVersion: "0.55.0", + latestVersion: nil, + expectedVersion: "latest", + shouldUpdate: false, + }, + { + name: "Should not update if expected version is invalid", + daemonVersion: "0.55.0", + latestVersion: nil, + expectedVersion: "development", + shouldUpdate: false, + }, + } + for idx, c := range testMatrix { + tmpFile := path.Join(t.TempDir(), fmt.Sprintf("update-test-%d.json", idx)) + recorder := peer.NewRecorder("") + sub := recorder.SubscribeToEvents() + defer recorder.UnsubscribeFromEvents(sub) + + m := NewManager(recorder, statemanager.New(tmpFile)) + m.update = &versionUpdateMock{latestVersion: c.latestVersion} + m.currentVersion = c.daemonVersion + m.autoUpdateSupported = func() bool { return true } + m.Start(context.Background()) + m.SetVersion(c.expectedVersion, false) + + ver, _ := waitForUpdateEvent(sub, 500*time.Millisecond) + updateTriggered := ver != "" + + if updateTriggered { + if c.expectedVersion == "latest" && c.latestVersion != nil && ver != c.latestVersion.String() { + t.Errorf("%s: Version mismatch, expected %v, got %v", c.name, c.latestVersion.String(), ver) + } else if c.expectedVersion != "latest" && c.expectedVersion != "development" && ver != c.expectedVersion { + t.Errorf("%s: Version mismatch, expected %v, got %v", c.name, c.expectedVersion, ver) + } + } + + if updateTriggered != c.shouldUpdate { + t.Errorf("%s: Update trigger mismatch, expected %v, got %v", c.name, c.shouldUpdate, updateTriggered) + } + m.Stop() + } +} + +func Test_EnforcedMetadata(t *testing.T) { + // Mode 1 (downloadOnly): no enforced metadata + tmpFile := path.Join(t.TempDir(), "update-test-mode1.json") + recorder := peer.NewRecorder("") + sub := recorder.SubscribeToEvents() + defer recorder.UnsubscribeFromEvents(sub) + + m := NewManager(recorder, statemanager.New(tmpFile)) + m.update = &versionUpdateMock{latestVersion: v.Must(v.NewSemver("1.0.1"))} + m.currentVersion = "1.0.0" + m.Start(context.Background()) + m.SetDownloadOnly() + + ver, enforced := waitForUpdateEvent(sub, 500*time.Millisecond) + if ver == "" { + t.Fatal("Mode 1: expected new_version_available event") + } + if enforced { + t.Error("Mode 1: expected no enforced metadata") + } + m.Stop() + + // Mode 2 (enforced, forceUpdate=false): enforced metadata present, no auto-install + tmpFile2 := path.Join(t.TempDir(), "update-test-mode2.json") + recorder2 := peer.NewRecorder("") + sub2 := recorder2.SubscribeToEvents() + defer recorder2.UnsubscribeFromEvents(sub2) + + m2 := NewManager(recorder2, statemanager.New(tmpFile2)) + m2.update = &versionUpdateMock{latestVersion: nil} + m2.currentVersion = "1.0.0" + m2.autoUpdateSupported = func() bool { return true } + m2.Start(context.Background()) + m2.SetVersion("1.0.1", false) + + ver, enforced2 := waitForUpdateEvent(sub2, 500*time.Millisecond) + if ver == "" { + t.Fatal("Mode 2: expected new_version_available event") + } + if !enforced2 { + t.Error("Mode 2: expected enforced metadata") + } + m2.Stop() +} + +// ensure the proto import is used +var _ = cProto.SystemEvent_INFO diff --git a/client/internal/updater/manager_test_helpers_test.go b/client/internal/updater/manager_test_helpers_test.go new file mode 100644 index 000000000..c7faee1f4 --- /dev/null +++ b/client/internal/updater/manager_test_helpers_test.go @@ -0,0 +1,56 @@ +package updater + +import ( + "strconv" + "time" + + v "github.com/hashicorp/go-version" + + "github.com/netbirdio/netbird/client/internal/peer" +) + +type versionUpdateMock struct { + latestVersion *v.Version + onUpdate func() +} + +func (m versionUpdateMock) StopWatch() {} + +func (m versionUpdateMock) SetDaemonVersion(newVersion string) bool { + return false +} + +func (m *versionUpdateMock) SetOnUpdateListener(updateFn func()) { + m.onUpdate = updateFn +} + +func (m versionUpdateMock) LatestVersion() *v.Version { + return m.latestVersion +} + +func (m versionUpdateMock) StartFetcher() {} + +// waitForUpdateEvent waits for a new_version_available event, returns the version string or "" on timeout. +func waitForUpdateEvent(sub *peer.EventSubscription, timeout time.Duration) (version string, enforced bool) { + timer := time.NewTimer(timeout) + defer timer.Stop() + for { + select { + case event, ok := <-sub.Events(): + if !ok { + return "", false + } + if val, ok := event.Metadata["new_version_available"]; ok { + enforced := false + if raw, ok := event.Metadata["enforced"]; ok { + if parsed, err := strconv.ParseBool(raw); err == nil { + enforced = parsed + } + } + return val, enforced + } + case <-timer.C: + return "", false + } + } +} diff --git a/client/internal/updatemanager/reposign/artifact.go b/client/internal/updater/reposign/artifact.go similarity index 100% rename from client/internal/updatemanager/reposign/artifact.go rename to client/internal/updater/reposign/artifact.go diff --git a/client/internal/updatemanager/reposign/artifact_test.go b/client/internal/updater/reposign/artifact_test.go similarity index 100% rename from client/internal/updatemanager/reposign/artifact_test.go rename to client/internal/updater/reposign/artifact_test.go diff --git a/client/internal/updatemanager/reposign/certs/root-pub.pem b/client/internal/updater/reposign/certs/root-pub.pem similarity index 100% rename from client/internal/updatemanager/reposign/certs/root-pub.pem rename to client/internal/updater/reposign/certs/root-pub.pem diff --git a/client/internal/updatemanager/reposign/certsdev/root-pub.pem b/client/internal/updater/reposign/certsdev/root-pub.pem similarity index 100% rename from client/internal/updatemanager/reposign/certsdev/root-pub.pem rename to client/internal/updater/reposign/certsdev/root-pub.pem diff --git a/client/internal/updatemanager/reposign/doc.go b/client/internal/updater/reposign/doc.go similarity index 100% rename from client/internal/updatemanager/reposign/doc.go rename to client/internal/updater/reposign/doc.go diff --git a/client/internal/updatemanager/reposign/embed_dev.go b/client/internal/updater/reposign/embed_dev.go similarity index 100% rename from client/internal/updatemanager/reposign/embed_dev.go rename to client/internal/updater/reposign/embed_dev.go diff --git a/client/internal/updatemanager/reposign/embed_prod.go b/client/internal/updater/reposign/embed_prod.go similarity index 100% rename from client/internal/updatemanager/reposign/embed_prod.go rename to client/internal/updater/reposign/embed_prod.go diff --git a/client/internal/updatemanager/reposign/key.go b/client/internal/updater/reposign/key.go similarity index 100% rename from client/internal/updatemanager/reposign/key.go rename to client/internal/updater/reposign/key.go diff --git a/client/internal/updatemanager/reposign/key_test.go b/client/internal/updater/reposign/key_test.go similarity index 100% rename from client/internal/updatemanager/reposign/key_test.go rename to client/internal/updater/reposign/key_test.go diff --git a/client/internal/updatemanager/reposign/revocation.go b/client/internal/updater/reposign/revocation.go similarity index 100% rename from client/internal/updatemanager/reposign/revocation.go rename to client/internal/updater/reposign/revocation.go diff --git a/client/internal/updatemanager/reposign/revocation_test.go b/client/internal/updater/reposign/revocation_test.go similarity index 100% rename from client/internal/updatemanager/reposign/revocation_test.go rename to client/internal/updater/reposign/revocation_test.go diff --git a/client/internal/updatemanager/reposign/root.go b/client/internal/updater/reposign/root.go similarity index 100% rename from client/internal/updatemanager/reposign/root.go rename to client/internal/updater/reposign/root.go diff --git a/client/internal/updatemanager/reposign/root_test.go b/client/internal/updater/reposign/root_test.go similarity index 100% rename from client/internal/updatemanager/reposign/root_test.go rename to client/internal/updater/reposign/root_test.go diff --git a/client/internal/updatemanager/reposign/signature.go b/client/internal/updater/reposign/signature.go similarity index 100% rename from client/internal/updatemanager/reposign/signature.go rename to client/internal/updater/reposign/signature.go diff --git a/client/internal/updatemanager/reposign/signature_test.go b/client/internal/updater/reposign/signature_test.go similarity index 100% rename from client/internal/updatemanager/reposign/signature_test.go rename to client/internal/updater/reposign/signature_test.go diff --git a/client/internal/updatemanager/reposign/verify.go b/client/internal/updater/reposign/verify.go similarity index 98% rename from client/internal/updatemanager/reposign/verify.go rename to client/internal/updater/reposign/verify.go index 0af2a8c9e..f64b26a30 100644 --- a/client/internal/updatemanager/reposign/verify.go +++ b/client/internal/updater/reposign/verify.go @@ -10,7 +10,7 @@ import ( log "github.com/sirupsen/logrus" - "github.com/netbirdio/netbird/client/internal/updatemanager/downloader" + "github.com/netbirdio/netbird/client/internal/updater/downloader" ) const ( diff --git a/client/internal/updatemanager/reposign/verify_test.go b/client/internal/updater/reposign/verify_test.go similarity index 100% rename from client/internal/updatemanager/reposign/verify_test.go rename to client/internal/updater/reposign/verify_test.go diff --git a/client/internal/updater/supported_darwin.go b/client/internal/updater/supported_darwin.go new file mode 100644 index 000000000..b27754366 --- /dev/null +++ b/client/internal/updater/supported_darwin.go @@ -0,0 +1,22 @@ +package updater + +import ( + "context" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/updater/installer" +) + +func isAutoUpdateSupported() bool { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + isBrew := !installer.TypeOfInstaller(ctx).Downloadable() + if isBrew { + log.Warnf("auto-update disabled on Homebrew installation") + return false + } + return true +} diff --git a/client/internal/updater/supported_other.go b/client/internal/updater/supported_other.go new file mode 100644 index 000000000..e09e8c3a3 --- /dev/null +++ b/client/internal/updater/supported_other.go @@ -0,0 +1,7 @@ +//go:build !windows && !darwin + +package updater + +func isAutoUpdateSupported() bool { + return false +} diff --git a/client/internal/updater/supported_windows.go b/client/internal/updater/supported_windows.go new file mode 100644 index 000000000..0c28878c7 --- /dev/null +++ b/client/internal/updater/supported_windows.go @@ -0,0 +1,5 @@ +package updater + +func isAutoUpdateSupported() bool { + return true +} diff --git a/client/internal/updatemanager/update.go b/client/internal/updater/update.go similarity index 90% rename from client/internal/updatemanager/update.go rename to client/internal/updater/update.go index 875b50b49..3056c77e1 100644 --- a/client/internal/updatemanager/update.go +++ b/client/internal/updater/update.go @@ -1,4 +1,4 @@ -package updatemanager +package updater import v "github.com/hashicorp/go-version" diff --git a/client/internal/wg_iface_monitor.go b/client/internal/wg_iface_monitor.go index 78d70c15b..a870c1145 100644 --- a/client/internal/wg_iface_monitor.go +++ b/client/internal/wg_iface_monitor.go @@ -9,6 +9,8 @@ import ( "time" log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/iface/netstack" ) // WGIfaceMonitor monitors the WireGuard interface lifecycle and restarts the engine @@ -35,6 +37,11 @@ func (m *WGIfaceMonitor) Start(ctx context.Context, ifaceName string) (shouldRes return false, errors.New("not supported on mobile platforms") } + if netstack.IsEnabled() { + log.Debugf("Interface monitor: skipped in netstack mode") + return false, nil + } + if ifaceName == "" { log.Debugf("Interface monitor: empty interface name, skipping monitor") return false, errors.New("empty interface name") diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index aafef41d3..3e2da7f4e 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -160,7 +160,7 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error { c.onHostDnsFn = func([]string) {} cfg.WgIface = interfaceName - c.connectClient = internal.NewConnectClient(ctx, cfg, c.recorder, false) + c.connectClient = internal.NewConnectClient(ctx, cfg, c.recorder) return c.connectClient.RunOniOS(fd, c.networkChangeListener, c.dnsManager, c.stateFile) } diff --git a/client/ios/NetBirdSDK/env_list.go b/client/ios/NetBirdSDK/env_list.go index 4800803d7..88ac97957 100644 --- a/client/ios/NetBirdSDK/env_list.go +++ b/client/ios/NetBirdSDK/env_list.go @@ -2,7 +2,10 @@ package NetBirdSDK -import "github.com/netbirdio/netbird/client/internal/peer" +import ( + "github.com/netbirdio/netbird/client/internal/lazyconn" + "github.com/netbirdio/netbird/client/internal/peer" +) // EnvList is an exported struct to be bound by gomobile type EnvList struct { @@ -32,3 +35,13 @@ func (el *EnvList) AllItems() map[string]string { func GetEnvKeyNBForceRelay() string { return peer.EnvKeyNBForceRelay } + +// GetEnvKeyNBLazyConn Exports the environment variable for the iOS client +func GetEnvKeyNBLazyConn() string { + return lazyconn.EnvEnableLazyConn +} + +// GetEnvKeyNBInactivityThreshold Exports the environment variable for the iOS client +func GetEnvKeyNBInactivityThreshold() string { + return lazyconn.EnvInactivityThreshold +} diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index 1d9d7233c..fa0b2f93b 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.6 -// protoc v6.32.1 +// protoc v6.33.1 // source: daemon.proto package proto @@ -88,6 +88,61 @@ func (LogLevel) EnumDescriptor() ([]byte, []int) { return file_daemon_proto_rawDescGZIP(), []int{0} } +type ExposeProtocol int32 + +const ( + ExposeProtocol_EXPOSE_HTTP ExposeProtocol = 0 + ExposeProtocol_EXPOSE_HTTPS ExposeProtocol = 1 + ExposeProtocol_EXPOSE_TCP ExposeProtocol = 2 + ExposeProtocol_EXPOSE_UDP ExposeProtocol = 3 + ExposeProtocol_EXPOSE_TLS ExposeProtocol = 4 +) + +// Enum value maps for ExposeProtocol. +var ( + ExposeProtocol_name = map[int32]string{ + 0: "EXPOSE_HTTP", + 1: "EXPOSE_HTTPS", + 2: "EXPOSE_TCP", + 3: "EXPOSE_UDP", + 4: "EXPOSE_TLS", + } + ExposeProtocol_value = map[string]int32{ + "EXPOSE_HTTP": 0, + "EXPOSE_HTTPS": 1, + "EXPOSE_TCP": 2, + "EXPOSE_UDP": 3, + "EXPOSE_TLS": 4, + } +) + +func (x ExposeProtocol) Enum() *ExposeProtocol { + p := new(ExposeProtocol) + *p = x + return p +} + +func (x ExposeProtocol) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ExposeProtocol) Descriptor() protoreflect.EnumDescriptor { + return file_daemon_proto_enumTypes[1].Descriptor() +} + +func (ExposeProtocol) Type() protoreflect.EnumType { + return &file_daemon_proto_enumTypes[1] +} + +func (x ExposeProtocol) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ExposeProtocol.Descriptor instead. +func (ExposeProtocol) EnumDescriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{1} +} + // avoid collision with loglevel enum type OSLifecycleRequest_CycleType int32 @@ -122,11 +177,11 @@ func (x OSLifecycleRequest_CycleType) String() string { } func (OSLifecycleRequest_CycleType) Descriptor() protoreflect.EnumDescriptor { - return file_daemon_proto_enumTypes[1].Descriptor() + return file_daemon_proto_enumTypes[2].Descriptor() } func (OSLifecycleRequest_CycleType) Type() protoreflect.EnumType { - return &file_daemon_proto_enumTypes[1] + return &file_daemon_proto_enumTypes[2] } func (x OSLifecycleRequest_CycleType) Number() protoreflect.EnumNumber { @@ -174,11 +229,11 @@ func (x SystemEvent_Severity) String() string { } func (SystemEvent_Severity) Descriptor() protoreflect.EnumDescriptor { - return file_daemon_proto_enumTypes[2].Descriptor() + return file_daemon_proto_enumTypes[3].Descriptor() } func (SystemEvent_Severity) Type() protoreflect.EnumType { - return &file_daemon_proto_enumTypes[2] + return &file_daemon_proto_enumTypes[3] } func (x SystemEvent_Severity) Number() protoreflect.EnumNumber { @@ -229,11 +284,11 @@ func (x SystemEvent_Category) String() string { } func (SystemEvent_Category) Descriptor() protoreflect.EnumDescriptor { - return file_daemon_proto_enumTypes[3].Descriptor() + return file_daemon_proto_enumTypes[4].Descriptor() } func (SystemEvent_Category) Type() protoreflect.EnumType { - return &file_daemon_proto_enumTypes[3] + return &file_daemon_proto_enumTypes[4] } func (x SystemEvent_Category) Number() protoreflect.EnumNumber { @@ -893,7 +948,6 @@ type UpRequest struct { state protoimpl.MessageState `protogen:"open.v1"` ProfileName *string `protobuf:"bytes,1,opt,name=profileName,proto3,oneof" json:"profileName,omitempty"` Username *string `protobuf:"bytes,2,opt,name=username,proto3,oneof" json:"username,omitempty"` - AutoUpdate *bool `protobuf:"varint,3,opt,name=autoUpdate,proto3,oneof" json:"autoUpdate,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -942,13 +996,6 @@ func (x *UpRequest) GetUsername() string { return "" } -func (x *UpRequest) GetAutoUpdate() bool { - if x != nil && x.AutoUpdate != nil { - return *x.AutoUpdate - } - return false -} - type UpResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -4980,6 +5027,94 @@ func (x *GetFeaturesResponse) GetDisableUpdateSettings() bool { return false } +type TriggerUpdateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TriggerUpdateRequest) Reset() { + *x = TriggerUpdateRequest{} + mi := &file_daemon_proto_msgTypes[73] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TriggerUpdateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TriggerUpdateRequest) ProtoMessage() {} + +func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[73] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TriggerUpdateRequest.ProtoReflect.Descriptor instead. +func (*TriggerUpdateRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{73} +} + +type TriggerUpdateResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` + ErrorMsg string `protobuf:"bytes,2,opt,name=errorMsg,proto3" json:"errorMsg,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TriggerUpdateResponse) Reset() { + *x = TriggerUpdateResponse{} + mi := &file_daemon_proto_msgTypes[74] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TriggerUpdateResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TriggerUpdateResponse) ProtoMessage() {} + +func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[74] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TriggerUpdateResponse.ProtoReflect.Descriptor instead. +func (*TriggerUpdateResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{74} +} + +func (x *TriggerUpdateResponse) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +func (x *TriggerUpdateResponse) GetErrorMsg() string { + if x != nil { + return x.ErrorMsg + } + return "" +} + // GetPeerSSHHostKeyRequest for retrieving SSH host key for a specific peer type GetPeerSSHHostKeyRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -4991,7 +5126,7 @@ type GetPeerSSHHostKeyRequest struct { func (x *GetPeerSSHHostKeyRequest) Reset() { *x = GetPeerSSHHostKeyRequest{} - mi := &file_daemon_proto_msgTypes[73] + mi := &file_daemon_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5003,7 +5138,7 @@ func (x *GetPeerSSHHostKeyRequest) String() string { func (*GetPeerSSHHostKeyRequest) ProtoMessage() {} func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[73] + mi := &file_daemon_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5016,7 +5151,7 @@ func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetPeerSSHHostKeyRequest.ProtoReflect.Descriptor instead. func (*GetPeerSSHHostKeyRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{73} + return file_daemon_proto_rawDescGZIP(), []int{75} } func (x *GetPeerSSHHostKeyRequest) GetPeerAddress() string { @@ -5043,7 +5178,7 @@ type GetPeerSSHHostKeyResponse struct { func (x *GetPeerSSHHostKeyResponse) Reset() { *x = GetPeerSSHHostKeyResponse{} - mi := &file_daemon_proto_msgTypes[74] + mi := &file_daemon_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5055,7 +5190,7 @@ func (x *GetPeerSSHHostKeyResponse) String() string { func (*GetPeerSSHHostKeyResponse) ProtoMessage() {} func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[74] + mi := &file_daemon_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5068,7 +5203,7 @@ func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetPeerSSHHostKeyResponse.ProtoReflect.Descriptor instead. func (*GetPeerSSHHostKeyResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{74} + return file_daemon_proto_rawDescGZIP(), []int{76} } func (x *GetPeerSSHHostKeyResponse) GetSshHostKey() []byte { @@ -5110,7 +5245,7 @@ type RequestJWTAuthRequest struct { func (x *RequestJWTAuthRequest) Reset() { *x = RequestJWTAuthRequest{} - mi := &file_daemon_proto_msgTypes[75] + mi := &file_daemon_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5122,7 +5257,7 @@ func (x *RequestJWTAuthRequest) String() string { func (*RequestJWTAuthRequest) ProtoMessage() {} func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[75] + mi := &file_daemon_proto_msgTypes[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5135,7 +5270,7 @@ func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RequestJWTAuthRequest.ProtoReflect.Descriptor instead. func (*RequestJWTAuthRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{75} + return file_daemon_proto_rawDescGZIP(), []int{77} } func (x *RequestJWTAuthRequest) GetHint() string { @@ -5168,7 +5303,7 @@ type RequestJWTAuthResponse struct { func (x *RequestJWTAuthResponse) Reset() { *x = RequestJWTAuthResponse{} - mi := &file_daemon_proto_msgTypes[76] + mi := &file_daemon_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5180,7 +5315,7 @@ func (x *RequestJWTAuthResponse) String() string { func (*RequestJWTAuthResponse) ProtoMessage() {} func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[76] + mi := &file_daemon_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5193,7 +5328,7 @@ func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RequestJWTAuthResponse.ProtoReflect.Descriptor instead. func (*RequestJWTAuthResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{76} + return file_daemon_proto_rawDescGZIP(), []int{78} } func (x *RequestJWTAuthResponse) GetVerificationURI() string { @@ -5258,7 +5393,7 @@ type WaitJWTTokenRequest struct { func (x *WaitJWTTokenRequest) Reset() { *x = WaitJWTTokenRequest{} - mi := &file_daemon_proto_msgTypes[77] + mi := &file_daemon_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5270,7 +5405,7 @@ func (x *WaitJWTTokenRequest) String() string { func (*WaitJWTTokenRequest) ProtoMessage() {} func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[77] + mi := &file_daemon_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5283,7 +5418,7 @@ func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WaitJWTTokenRequest.ProtoReflect.Descriptor instead. func (*WaitJWTTokenRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{77} + return file_daemon_proto_rawDescGZIP(), []int{79} } func (x *WaitJWTTokenRequest) GetDeviceCode() string { @@ -5315,7 +5450,7 @@ type WaitJWTTokenResponse struct { func (x *WaitJWTTokenResponse) Reset() { *x = WaitJWTTokenResponse{} - mi := &file_daemon_proto_msgTypes[78] + mi := &file_daemon_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5327,7 +5462,7 @@ func (x *WaitJWTTokenResponse) String() string { func (*WaitJWTTokenResponse) ProtoMessage() {} func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[78] + mi := &file_daemon_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5340,7 +5475,7 @@ func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use WaitJWTTokenResponse.ProtoReflect.Descriptor instead. func (*WaitJWTTokenResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{78} + return file_daemon_proto_rawDescGZIP(), []int{80} } func (x *WaitJWTTokenResponse) GetToken() string { @@ -5373,7 +5508,7 @@ type StartCPUProfileRequest struct { func (x *StartCPUProfileRequest) Reset() { *x = StartCPUProfileRequest{} - mi := &file_daemon_proto_msgTypes[79] + mi := &file_daemon_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5385,7 +5520,7 @@ func (x *StartCPUProfileRequest) String() string { func (*StartCPUProfileRequest) ProtoMessage() {} func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[79] + mi := &file_daemon_proto_msgTypes[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5398,7 +5533,7 @@ func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartCPUProfileRequest.ProtoReflect.Descriptor instead. func (*StartCPUProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{79} + return file_daemon_proto_rawDescGZIP(), []int{81} } // StartCPUProfileResponse confirms CPU profiling has started @@ -5410,7 +5545,7 @@ type StartCPUProfileResponse struct { func (x *StartCPUProfileResponse) Reset() { *x = StartCPUProfileResponse{} - mi := &file_daemon_proto_msgTypes[80] + mi := &file_daemon_proto_msgTypes[82] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5422,7 +5557,7 @@ func (x *StartCPUProfileResponse) String() string { func (*StartCPUProfileResponse) ProtoMessage() {} func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[80] + mi := &file_daemon_proto_msgTypes[82] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5435,7 +5570,7 @@ func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StartCPUProfileResponse.ProtoReflect.Descriptor instead. func (*StartCPUProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{80} + return file_daemon_proto_rawDescGZIP(), []int{82} } // StopCPUProfileRequest for stopping CPU profiling @@ -5447,7 +5582,7 @@ type StopCPUProfileRequest struct { func (x *StopCPUProfileRequest) Reset() { *x = StopCPUProfileRequest{} - mi := &file_daemon_proto_msgTypes[81] + mi := &file_daemon_proto_msgTypes[83] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5459,7 +5594,7 @@ func (x *StopCPUProfileRequest) String() string { func (*StopCPUProfileRequest) ProtoMessage() {} func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[81] + mi := &file_daemon_proto_msgTypes[83] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5472,7 +5607,7 @@ func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StopCPUProfileRequest.ProtoReflect.Descriptor instead. func (*StopCPUProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{81} + return file_daemon_proto_rawDescGZIP(), []int{83} } // StopCPUProfileResponse confirms CPU profiling has stopped @@ -5484,7 +5619,7 @@ type StopCPUProfileResponse struct { func (x *StopCPUProfileResponse) Reset() { *x = StopCPUProfileResponse{} - mi := &file_daemon_proto_msgTypes[82] + mi := &file_daemon_proto_msgTypes[84] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5496,7 +5631,7 @@ func (x *StopCPUProfileResponse) String() string { func (*StopCPUProfileResponse) ProtoMessage() {} func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[82] + mi := &file_daemon_proto_msgTypes[84] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5509,7 +5644,7 @@ func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StopCPUProfileResponse.ProtoReflect.Descriptor instead. func (*StopCPUProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{82} + return file_daemon_proto_rawDescGZIP(), []int{84} } type InstallerResultRequest struct { @@ -5520,7 +5655,7 @@ type InstallerResultRequest struct { func (x *InstallerResultRequest) Reset() { *x = InstallerResultRequest{} - mi := &file_daemon_proto_msgTypes[83] + mi := &file_daemon_proto_msgTypes[85] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5532,7 +5667,7 @@ func (x *InstallerResultRequest) String() string { func (*InstallerResultRequest) ProtoMessage() {} func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[83] + mi := &file_daemon_proto_msgTypes[85] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5545,7 +5680,7 @@ func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use InstallerResultRequest.ProtoReflect.Descriptor instead. func (*InstallerResultRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{83} + return file_daemon_proto_rawDescGZIP(), []int{85} } type InstallerResultResponse struct { @@ -5558,7 +5693,7 @@ type InstallerResultResponse struct { func (x *InstallerResultResponse) Reset() { *x = InstallerResultResponse{} - mi := &file_daemon_proto_msgTypes[84] + mi := &file_daemon_proto_msgTypes[86] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5570,7 +5705,7 @@ func (x *InstallerResultResponse) String() string { func (*InstallerResultResponse) ProtoMessage() {} func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[84] + mi := &file_daemon_proto_msgTypes[86] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5583,7 +5718,7 @@ func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use InstallerResultResponse.ProtoReflect.Descriptor instead. func (*InstallerResultResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{84} + return file_daemon_proto_rawDescGZIP(), []int{86} } func (x *InstallerResultResponse) GetSuccess() bool { @@ -5600,6 +5735,240 @@ func (x *InstallerResultResponse) GetErrorMsg() string { return "" } +type ExposeServiceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Port uint32 `protobuf:"varint,1,opt,name=port,proto3" json:"port,omitempty"` + Protocol ExposeProtocol `protobuf:"varint,2,opt,name=protocol,proto3,enum=daemon.ExposeProtocol" json:"protocol,omitempty"` + Pin string `protobuf:"bytes,3,opt,name=pin,proto3" json:"pin,omitempty"` + Password string `protobuf:"bytes,4,opt,name=password,proto3" json:"password,omitempty"` + UserGroups []string `protobuf:"bytes,5,rep,name=user_groups,json=userGroups,proto3" json:"user_groups,omitempty"` + Domain string `protobuf:"bytes,6,opt,name=domain,proto3" json:"domain,omitempty"` + NamePrefix string `protobuf:"bytes,7,opt,name=name_prefix,json=namePrefix,proto3" json:"name_prefix,omitempty"` + ListenPort uint32 `protobuf:"varint,8,opt,name=listen_port,json=listenPort,proto3" json:"listen_port,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExposeServiceRequest) Reset() { + *x = ExposeServiceRequest{} + mi := &file_daemon_proto_msgTypes[87] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExposeServiceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExposeServiceRequest) ProtoMessage() {} + +func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[87] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExposeServiceRequest.ProtoReflect.Descriptor instead. +func (*ExposeServiceRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{87} +} + +func (x *ExposeServiceRequest) GetPort() uint32 { + if x != nil { + return x.Port + } + return 0 +} + +func (x *ExposeServiceRequest) GetProtocol() ExposeProtocol { + if x != nil { + return x.Protocol + } + return ExposeProtocol_EXPOSE_HTTP +} + +func (x *ExposeServiceRequest) GetPin() string { + if x != nil { + return x.Pin + } + return "" +} + +func (x *ExposeServiceRequest) GetPassword() string { + if x != nil { + return x.Password + } + return "" +} + +func (x *ExposeServiceRequest) GetUserGroups() []string { + if x != nil { + return x.UserGroups + } + return nil +} + +func (x *ExposeServiceRequest) GetDomain() string { + if x != nil { + return x.Domain + } + return "" +} + +func (x *ExposeServiceRequest) GetNamePrefix() string { + if x != nil { + return x.NamePrefix + } + return "" +} + +func (x *ExposeServiceRequest) GetListenPort() uint32 { + if x != nil { + return x.ListenPort + } + return 0 +} + +type ExposeServiceEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Event: + // + // *ExposeServiceEvent_Ready + Event isExposeServiceEvent_Event `protobuf_oneof:"event"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExposeServiceEvent) Reset() { + *x = ExposeServiceEvent{} + mi := &file_daemon_proto_msgTypes[88] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExposeServiceEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExposeServiceEvent) ProtoMessage() {} + +func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[88] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExposeServiceEvent.ProtoReflect.Descriptor instead. +func (*ExposeServiceEvent) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{88} +} + +func (x *ExposeServiceEvent) GetEvent() isExposeServiceEvent_Event { + if x != nil { + return x.Event + } + return nil +} + +func (x *ExposeServiceEvent) GetReady() *ExposeServiceReady { + if x != nil { + if x, ok := x.Event.(*ExposeServiceEvent_Ready); ok { + return x.Ready + } + } + return nil +} + +type isExposeServiceEvent_Event interface { + isExposeServiceEvent_Event() +} + +type ExposeServiceEvent_Ready struct { + Ready *ExposeServiceReady `protobuf:"bytes,1,opt,name=ready,proto3,oneof"` +} + +func (*ExposeServiceEvent_Ready) isExposeServiceEvent_Event() {} + +type ExposeServiceReady struct { + state protoimpl.MessageState `protogen:"open.v1"` + ServiceName string `protobuf:"bytes,1,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"` + ServiceUrl string `protobuf:"bytes,2,opt,name=service_url,json=serviceUrl,proto3" json:"service_url,omitempty"` + Domain string `protobuf:"bytes,3,opt,name=domain,proto3" json:"domain,omitempty"` + PortAutoAssigned bool `protobuf:"varint,4,opt,name=port_auto_assigned,json=portAutoAssigned,proto3" json:"port_auto_assigned,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExposeServiceReady) Reset() { + *x = ExposeServiceReady{} + mi := &file_daemon_proto_msgTypes[89] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExposeServiceReady) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExposeServiceReady) ProtoMessage() {} + +func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[89] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExposeServiceReady.ProtoReflect.Descriptor instead. +func (*ExposeServiceReady) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{89} +} + +func (x *ExposeServiceReady) GetServiceName() string { + if x != nil { + return x.ServiceName + } + return "" +} + +func (x *ExposeServiceReady) GetServiceUrl() string { + if x != nil { + return x.ServiceUrl + } + return "" +} + +func (x *ExposeServiceReady) GetDomain() string { + if x != nil { + return x.Domain + } + return "" +} + +func (x *ExposeServiceReady) GetPortAutoAssigned() bool { + if x != nil { + return x.PortAutoAssigned + } + return false +} + type PortInfo_Range struct { state protoimpl.MessageState `protogen:"open.v1"` Start uint32 `protobuf:"varint,1,opt,name=start,proto3" json:"start,omitempty"` @@ -5610,7 +5979,7 @@ type PortInfo_Range struct { func (x *PortInfo_Range) Reset() { *x = PortInfo_Range{} - mi := &file_daemon_proto_msgTypes[86] + mi := &file_daemon_proto_msgTypes[91] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5622,7 +5991,7 @@ func (x *PortInfo_Range) String() string { func (*PortInfo_Range) ProtoMessage() {} func (x *PortInfo_Range) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[86] + mi := &file_daemon_proto_msgTypes[91] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5746,16 +6115,12 @@ const file_daemon_proto_rawDesc = "" + "\buserCode\x18\x01 \x01(\tR\buserCode\x12\x1a\n" + "\bhostname\x18\x02 \x01(\tR\bhostname\",\n" + "\x14WaitSSOLoginResponse\x12\x14\n" + - "\x05email\x18\x01 \x01(\tR\x05email\"\xa4\x01\n" + + "\x05email\x18\x01 \x01(\tR\x05email\"v\n" + "\tUpRequest\x12%\n" + "\vprofileName\x18\x01 \x01(\tH\x00R\vprofileName\x88\x01\x01\x12\x1f\n" + - "\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01\x12#\n" + - "\n" + - "autoUpdate\x18\x03 \x01(\bH\x02R\n" + - "autoUpdate\x88\x01\x01B\x0e\n" + + "\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01B\x0e\n" + "\f_profileNameB\v\n" + - "\t_usernameB\r\n" + - "\v_autoUpdate\"\f\n" + + "\t_usernameJ\x04\b\x03\x10\x04\"\f\n" + "\n" + "UpResponse\"\xa1\x01\n" + "\rStatusRequest\x12,\n" + @@ -6110,7 +6475,11 @@ const file_daemon_proto_rawDesc = "" + "\x12GetFeaturesRequest\"x\n" + "\x13GetFeaturesResponse\x12)\n" + "\x10disable_profiles\x18\x01 \x01(\bR\x0fdisableProfiles\x126\n" + - "\x17disable_update_settings\x18\x02 \x01(\bR\x15disableUpdateSettings\"<\n" + + "\x17disable_update_settings\x18\x02 \x01(\bR\x15disableUpdateSettings\"\x16\n" + + "\x14TriggerUpdateRequest\"M\n" + + "\x15TriggerUpdateResponse\x12\x18\n" + + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x1a\n" + + "\berrorMsg\x18\x02 \x01(\tR\berrorMsg\"<\n" + "\x18GetPeerSSHHostKeyRequest\x12 \n" + "\vpeerAddress\x18\x01 \x01(\tR\vpeerAddress\"\x85\x01\n" + "\x19GetPeerSSHHostKeyResponse\x12\x1e\n" + @@ -6149,7 +6518,28 @@ const file_daemon_proto_rawDesc = "" + "\x16InstallerResultRequest\"O\n" + "\x17InstallerResultResponse\x12\x18\n" + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x1a\n" + - "\berrorMsg\x18\x02 \x01(\tR\berrorMsg*b\n" + + "\berrorMsg\x18\x02 \x01(\tR\berrorMsg\"\x87\x02\n" + + "\x14ExposeServiceRequest\x12\x12\n" + + "\x04port\x18\x01 \x01(\rR\x04port\x122\n" + + "\bprotocol\x18\x02 \x01(\x0e2\x16.daemon.ExposeProtocolR\bprotocol\x12\x10\n" + + "\x03pin\x18\x03 \x01(\tR\x03pin\x12\x1a\n" + + "\bpassword\x18\x04 \x01(\tR\bpassword\x12\x1f\n" + + "\vuser_groups\x18\x05 \x03(\tR\n" + + "userGroups\x12\x16\n" + + "\x06domain\x18\x06 \x01(\tR\x06domain\x12\x1f\n" + + "\vname_prefix\x18\a \x01(\tR\n" + + "namePrefix\x12\x1f\n" + + "\vlisten_port\x18\b \x01(\rR\n" + + "listenPort\"Q\n" + + "\x12ExposeServiceEvent\x122\n" + + "\x05ready\x18\x01 \x01(\v2\x1a.daemon.ExposeServiceReadyH\x00R\x05readyB\a\n" + + "\x05event\"\x9e\x01\n" + + "\x12ExposeServiceReady\x12!\n" + + "\fservice_name\x18\x01 \x01(\tR\vserviceName\x12\x1f\n" + + "\vservice_url\x18\x02 \x01(\tR\n" + + "serviceUrl\x12\x16\n" + + "\x06domain\x18\x03 \x01(\tR\x06domain\x12,\n" + + "\x12port_auto_assigned\x18\x04 \x01(\bR\x10portAutoAssigned*b\n" + "\bLogLevel\x12\v\n" + "\aUNKNOWN\x10\x00\x12\t\n" + "\x05PANIC\x10\x01\x12\t\n" + @@ -6158,7 +6548,16 @@ const file_daemon_proto_rawDesc = "" + "\x04WARN\x10\x04\x12\b\n" + "\x04INFO\x10\x05\x12\t\n" + "\x05DEBUG\x10\x06\x12\t\n" + - "\x05TRACE\x10\a2\xdd\x14\n" + + "\x05TRACE\x10\a*c\n" + + "\x0eExposeProtocol\x12\x0f\n" + + "\vEXPOSE_HTTP\x10\x00\x12\x10\n" + + "\fEXPOSE_HTTPS\x10\x01\x12\x0e\n" + + "\n" + + "EXPOSE_TCP\x10\x02\x12\x0e\n" + + "\n" + + "EXPOSE_UDP\x10\x03\x12\x0e\n" + + "\n" + + "EXPOSE_TLS\x10\x042\xfc\x15\n" + "\rDaemonService\x126\n" + "\x05Login\x12\x14.daemon.LoginRequest\x1a\x15.daemon.LoginResponse\"\x00\x12K\n" + "\fWaitSSOLogin\x12\x1b.daemon.WaitSSOLoginRequest\x1a\x1c.daemon.WaitSSOLoginResponse\"\x00\x12-\n" + @@ -6190,14 +6589,16 @@ const file_daemon_proto_rawDesc = "" + "\fListProfiles\x12\x1b.daemon.ListProfilesRequest\x1a\x1c.daemon.ListProfilesResponse\"\x00\x12W\n" + "\x10GetActiveProfile\x12\x1f.daemon.GetActiveProfileRequest\x1a .daemon.GetActiveProfileResponse\"\x00\x129\n" + "\x06Logout\x12\x15.daemon.LogoutRequest\x1a\x16.daemon.LogoutResponse\"\x00\x12H\n" + - "\vGetFeatures\x12\x1a.daemon.GetFeaturesRequest\x1a\x1b.daemon.GetFeaturesResponse\"\x00\x12Z\n" + + "\vGetFeatures\x12\x1a.daemon.GetFeaturesRequest\x1a\x1b.daemon.GetFeaturesResponse\"\x00\x12N\n" + + "\rTriggerUpdate\x12\x1c.daemon.TriggerUpdateRequest\x1a\x1d.daemon.TriggerUpdateResponse\"\x00\x12Z\n" + "\x11GetPeerSSHHostKey\x12 .daemon.GetPeerSSHHostKeyRequest\x1a!.daemon.GetPeerSSHHostKeyResponse\"\x00\x12Q\n" + "\x0eRequestJWTAuth\x12\x1d.daemon.RequestJWTAuthRequest\x1a\x1e.daemon.RequestJWTAuthResponse\"\x00\x12K\n" + "\fWaitJWTToken\x12\x1b.daemon.WaitJWTTokenRequest\x1a\x1c.daemon.WaitJWTTokenResponse\"\x00\x12T\n" + "\x0fStartCPUProfile\x12\x1e.daemon.StartCPUProfileRequest\x1a\x1f.daemon.StartCPUProfileResponse\"\x00\x12Q\n" + "\x0eStopCPUProfile\x12\x1d.daemon.StopCPUProfileRequest\x1a\x1e.daemon.StopCPUProfileResponse\"\x00\x12N\n" + "\x11NotifyOSLifecycle\x12\x1a.daemon.OSLifecycleRequest\x1a\x1b.daemon.OSLifecycleResponse\"\x00\x12W\n" + - "\x12GetInstallerResult\x12\x1e.daemon.InstallerResultRequest\x1a\x1f.daemon.InstallerResultResponse\"\x00B\bZ\x06/protob\x06proto3" + "\x12GetInstallerResult\x12\x1e.daemon.InstallerResultRequest\x1a\x1f.daemon.InstallerResultResponse\"\x00\x12M\n" + + "\rExposeService\x12\x1c.daemon.ExposeServiceRequest\x1a\x1a.daemon.ExposeServiceEvent\"\x000\x01B\bZ\x06/protob\x06proto3" var ( file_daemon_proto_rawDescOnce sync.Once @@ -6211,214 +6612,226 @@ func file_daemon_proto_rawDescGZIP() []byte { return file_daemon_proto_rawDescData } -var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 4) -var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 88) +var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 5) +var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 93) var file_daemon_proto_goTypes = []any{ (LogLevel)(0), // 0: daemon.LogLevel - (OSLifecycleRequest_CycleType)(0), // 1: daemon.OSLifecycleRequest.CycleType - (SystemEvent_Severity)(0), // 2: daemon.SystemEvent.Severity - (SystemEvent_Category)(0), // 3: daemon.SystemEvent.Category - (*EmptyRequest)(nil), // 4: daemon.EmptyRequest - (*OSLifecycleRequest)(nil), // 5: daemon.OSLifecycleRequest - (*OSLifecycleResponse)(nil), // 6: daemon.OSLifecycleResponse - (*LoginRequest)(nil), // 7: daemon.LoginRequest - (*LoginResponse)(nil), // 8: daemon.LoginResponse - (*WaitSSOLoginRequest)(nil), // 9: daemon.WaitSSOLoginRequest - (*WaitSSOLoginResponse)(nil), // 10: daemon.WaitSSOLoginResponse - (*UpRequest)(nil), // 11: daemon.UpRequest - (*UpResponse)(nil), // 12: daemon.UpResponse - (*StatusRequest)(nil), // 13: daemon.StatusRequest - (*StatusResponse)(nil), // 14: daemon.StatusResponse - (*DownRequest)(nil), // 15: daemon.DownRequest - (*DownResponse)(nil), // 16: daemon.DownResponse - (*GetConfigRequest)(nil), // 17: daemon.GetConfigRequest - (*GetConfigResponse)(nil), // 18: daemon.GetConfigResponse - (*PeerState)(nil), // 19: daemon.PeerState - (*LocalPeerState)(nil), // 20: daemon.LocalPeerState - (*SignalState)(nil), // 21: daemon.SignalState - (*ManagementState)(nil), // 22: daemon.ManagementState - (*RelayState)(nil), // 23: daemon.RelayState - (*NSGroupState)(nil), // 24: daemon.NSGroupState - (*SSHSessionInfo)(nil), // 25: daemon.SSHSessionInfo - (*SSHServerState)(nil), // 26: daemon.SSHServerState - (*FullStatus)(nil), // 27: daemon.FullStatus - (*ListNetworksRequest)(nil), // 28: daemon.ListNetworksRequest - (*ListNetworksResponse)(nil), // 29: daemon.ListNetworksResponse - (*SelectNetworksRequest)(nil), // 30: daemon.SelectNetworksRequest - (*SelectNetworksResponse)(nil), // 31: daemon.SelectNetworksResponse - (*IPList)(nil), // 32: daemon.IPList - (*Network)(nil), // 33: daemon.Network - (*PortInfo)(nil), // 34: daemon.PortInfo - (*ForwardingRule)(nil), // 35: daemon.ForwardingRule - (*ForwardingRulesResponse)(nil), // 36: daemon.ForwardingRulesResponse - (*DebugBundleRequest)(nil), // 37: daemon.DebugBundleRequest - (*DebugBundleResponse)(nil), // 38: daemon.DebugBundleResponse - (*GetLogLevelRequest)(nil), // 39: daemon.GetLogLevelRequest - (*GetLogLevelResponse)(nil), // 40: daemon.GetLogLevelResponse - (*SetLogLevelRequest)(nil), // 41: daemon.SetLogLevelRequest - (*SetLogLevelResponse)(nil), // 42: daemon.SetLogLevelResponse - (*State)(nil), // 43: daemon.State - (*ListStatesRequest)(nil), // 44: daemon.ListStatesRequest - (*ListStatesResponse)(nil), // 45: daemon.ListStatesResponse - (*CleanStateRequest)(nil), // 46: daemon.CleanStateRequest - (*CleanStateResponse)(nil), // 47: daemon.CleanStateResponse - (*DeleteStateRequest)(nil), // 48: daemon.DeleteStateRequest - (*DeleteStateResponse)(nil), // 49: daemon.DeleteStateResponse - (*SetSyncResponsePersistenceRequest)(nil), // 50: daemon.SetSyncResponsePersistenceRequest - (*SetSyncResponsePersistenceResponse)(nil), // 51: daemon.SetSyncResponsePersistenceResponse - (*TCPFlags)(nil), // 52: daemon.TCPFlags - (*TracePacketRequest)(nil), // 53: daemon.TracePacketRequest - (*TraceStage)(nil), // 54: daemon.TraceStage - (*TracePacketResponse)(nil), // 55: daemon.TracePacketResponse - (*SubscribeRequest)(nil), // 56: daemon.SubscribeRequest - (*SystemEvent)(nil), // 57: daemon.SystemEvent - (*GetEventsRequest)(nil), // 58: daemon.GetEventsRequest - (*GetEventsResponse)(nil), // 59: daemon.GetEventsResponse - (*SwitchProfileRequest)(nil), // 60: daemon.SwitchProfileRequest - (*SwitchProfileResponse)(nil), // 61: daemon.SwitchProfileResponse - (*SetConfigRequest)(nil), // 62: daemon.SetConfigRequest - (*SetConfigResponse)(nil), // 63: daemon.SetConfigResponse - (*AddProfileRequest)(nil), // 64: daemon.AddProfileRequest - (*AddProfileResponse)(nil), // 65: daemon.AddProfileResponse - (*RemoveProfileRequest)(nil), // 66: daemon.RemoveProfileRequest - (*RemoveProfileResponse)(nil), // 67: daemon.RemoveProfileResponse - (*ListProfilesRequest)(nil), // 68: daemon.ListProfilesRequest - (*ListProfilesResponse)(nil), // 69: daemon.ListProfilesResponse - (*Profile)(nil), // 70: daemon.Profile - (*GetActiveProfileRequest)(nil), // 71: daemon.GetActiveProfileRequest - (*GetActiveProfileResponse)(nil), // 72: daemon.GetActiveProfileResponse - (*LogoutRequest)(nil), // 73: daemon.LogoutRequest - (*LogoutResponse)(nil), // 74: daemon.LogoutResponse - (*GetFeaturesRequest)(nil), // 75: daemon.GetFeaturesRequest - (*GetFeaturesResponse)(nil), // 76: daemon.GetFeaturesResponse - (*GetPeerSSHHostKeyRequest)(nil), // 77: daemon.GetPeerSSHHostKeyRequest - (*GetPeerSSHHostKeyResponse)(nil), // 78: daemon.GetPeerSSHHostKeyResponse - (*RequestJWTAuthRequest)(nil), // 79: daemon.RequestJWTAuthRequest - (*RequestJWTAuthResponse)(nil), // 80: daemon.RequestJWTAuthResponse - (*WaitJWTTokenRequest)(nil), // 81: daemon.WaitJWTTokenRequest - (*WaitJWTTokenResponse)(nil), // 82: daemon.WaitJWTTokenResponse - (*StartCPUProfileRequest)(nil), // 83: daemon.StartCPUProfileRequest - (*StartCPUProfileResponse)(nil), // 84: daemon.StartCPUProfileResponse - (*StopCPUProfileRequest)(nil), // 85: daemon.StopCPUProfileRequest - (*StopCPUProfileResponse)(nil), // 86: daemon.StopCPUProfileResponse - (*InstallerResultRequest)(nil), // 87: daemon.InstallerResultRequest - (*InstallerResultResponse)(nil), // 88: daemon.InstallerResultResponse - nil, // 89: daemon.Network.ResolvedIPsEntry - (*PortInfo_Range)(nil), // 90: daemon.PortInfo.Range - nil, // 91: daemon.SystemEvent.MetadataEntry - (*durationpb.Duration)(nil), // 92: google.protobuf.Duration - (*timestamppb.Timestamp)(nil), // 93: google.protobuf.Timestamp + (ExposeProtocol)(0), // 1: daemon.ExposeProtocol + (OSLifecycleRequest_CycleType)(0), // 2: daemon.OSLifecycleRequest.CycleType + (SystemEvent_Severity)(0), // 3: daemon.SystemEvent.Severity + (SystemEvent_Category)(0), // 4: daemon.SystemEvent.Category + (*EmptyRequest)(nil), // 5: daemon.EmptyRequest + (*OSLifecycleRequest)(nil), // 6: daemon.OSLifecycleRequest + (*OSLifecycleResponse)(nil), // 7: daemon.OSLifecycleResponse + (*LoginRequest)(nil), // 8: daemon.LoginRequest + (*LoginResponse)(nil), // 9: daemon.LoginResponse + (*WaitSSOLoginRequest)(nil), // 10: daemon.WaitSSOLoginRequest + (*WaitSSOLoginResponse)(nil), // 11: daemon.WaitSSOLoginResponse + (*UpRequest)(nil), // 12: daemon.UpRequest + (*UpResponse)(nil), // 13: daemon.UpResponse + (*StatusRequest)(nil), // 14: daemon.StatusRequest + (*StatusResponse)(nil), // 15: daemon.StatusResponse + (*DownRequest)(nil), // 16: daemon.DownRequest + (*DownResponse)(nil), // 17: daemon.DownResponse + (*GetConfigRequest)(nil), // 18: daemon.GetConfigRequest + (*GetConfigResponse)(nil), // 19: daemon.GetConfigResponse + (*PeerState)(nil), // 20: daemon.PeerState + (*LocalPeerState)(nil), // 21: daemon.LocalPeerState + (*SignalState)(nil), // 22: daemon.SignalState + (*ManagementState)(nil), // 23: daemon.ManagementState + (*RelayState)(nil), // 24: daemon.RelayState + (*NSGroupState)(nil), // 25: daemon.NSGroupState + (*SSHSessionInfo)(nil), // 26: daemon.SSHSessionInfo + (*SSHServerState)(nil), // 27: daemon.SSHServerState + (*FullStatus)(nil), // 28: daemon.FullStatus + (*ListNetworksRequest)(nil), // 29: daemon.ListNetworksRequest + (*ListNetworksResponse)(nil), // 30: daemon.ListNetworksResponse + (*SelectNetworksRequest)(nil), // 31: daemon.SelectNetworksRequest + (*SelectNetworksResponse)(nil), // 32: daemon.SelectNetworksResponse + (*IPList)(nil), // 33: daemon.IPList + (*Network)(nil), // 34: daemon.Network + (*PortInfo)(nil), // 35: daemon.PortInfo + (*ForwardingRule)(nil), // 36: daemon.ForwardingRule + (*ForwardingRulesResponse)(nil), // 37: daemon.ForwardingRulesResponse + (*DebugBundleRequest)(nil), // 38: daemon.DebugBundleRequest + (*DebugBundleResponse)(nil), // 39: daemon.DebugBundleResponse + (*GetLogLevelRequest)(nil), // 40: daemon.GetLogLevelRequest + (*GetLogLevelResponse)(nil), // 41: daemon.GetLogLevelResponse + (*SetLogLevelRequest)(nil), // 42: daemon.SetLogLevelRequest + (*SetLogLevelResponse)(nil), // 43: daemon.SetLogLevelResponse + (*State)(nil), // 44: daemon.State + (*ListStatesRequest)(nil), // 45: daemon.ListStatesRequest + (*ListStatesResponse)(nil), // 46: daemon.ListStatesResponse + (*CleanStateRequest)(nil), // 47: daemon.CleanStateRequest + (*CleanStateResponse)(nil), // 48: daemon.CleanStateResponse + (*DeleteStateRequest)(nil), // 49: daemon.DeleteStateRequest + (*DeleteStateResponse)(nil), // 50: daemon.DeleteStateResponse + (*SetSyncResponsePersistenceRequest)(nil), // 51: daemon.SetSyncResponsePersistenceRequest + (*SetSyncResponsePersistenceResponse)(nil), // 52: daemon.SetSyncResponsePersistenceResponse + (*TCPFlags)(nil), // 53: daemon.TCPFlags + (*TracePacketRequest)(nil), // 54: daemon.TracePacketRequest + (*TraceStage)(nil), // 55: daemon.TraceStage + (*TracePacketResponse)(nil), // 56: daemon.TracePacketResponse + (*SubscribeRequest)(nil), // 57: daemon.SubscribeRequest + (*SystemEvent)(nil), // 58: daemon.SystemEvent + (*GetEventsRequest)(nil), // 59: daemon.GetEventsRequest + (*GetEventsResponse)(nil), // 60: daemon.GetEventsResponse + (*SwitchProfileRequest)(nil), // 61: daemon.SwitchProfileRequest + (*SwitchProfileResponse)(nil), // 62: daemon.SwitchProfileResponse + (*SetConfigRequest)(nil), // 63: daemon.SetConfigRequest + (*SetConfigResponse)(nil), // 64: daemon.SetConfigResponse + (*AddProfileRequest)(nil), // 65: daemon.AddProfileRequest + (*AddProfileResponse)(nil), // 66: daemon.AddProfileResponse + (*RemoveProfileRequest)(nil), // 67: daemon.RemoveProfileRequest + (*RemoveProfileResponse)(nil), // 68: daemon.RemoveProfileResponse + (*ListProfilesRequest)(nil), // 69: daemon.ListProfilesRequest + (*ListProfilesResponse)(nil), // 70: daemon.ListProfilesResponse + (*Profile)(nil), // 71: daemon.Profile + (*GetActiveProfileRequest)(nil), // 72: daemon.GetActiveProfileRequest + (*GetActiveProfileResponse)(nil), // 73: daemon.GetActiveProfileResponse + (*LogoutRequest)(nil), // 74: daemon.LogoutRequest + (*LogoutResponse)(nil), // 75: daemon.LogoutResponse + (*GetFeaturesRequest)(nil), // 76: daemon.GetFeaturesRequest + (*GetFeaturesResponse)(nil), // 77: daemon.GetFeaturesResponse + (*TriggerUpdateRequest)(nil), // 78: daemon.TriggerUpdateRequest + (*TriggerUpdateResponse)(nil), // 79: daemon.TriggerUpdateResponse + (*GetPeerSSHHostKeyRequest)(nil), // 80: daemon.GetPeerSSHHostKeyRequest + (*GetPeerSSHHostKeyResponse)(nil), // 81: daemon.GetPeerSSHHostKeyResponse + (*RequestJWTAuthRequest)(nil), // 82: daemon.RequestJWTAuthRequest + (*RequestJWTAuthResponse)(nil), // 83: daemon.RequestJWTAuthResponse + (*WaitJWTTokenRequest)(nil), // 84: daemon.WaitJWTTokenRequest + (*WaitJWTTokenResponse)(nil), // 85: daemon.WaitJWTTokenResponse + (*StartCPUProfileRequest)(nil), // 86: daemon.StartCPUProfileRequest + (*StartCPUProfileResponse)(nil), // 87: daemon.StartCPUProfileResponse + (*StopCPUProfileRequest)(nil), // 88: daemon.StopCPUProfileRequest + (*StopCPUProfileResponse)(nil), // 89: daemon.StopCPUProfileResponse + (*InstallerResultRequest)(nil), // 90: daemon.InstallerResultRequest + (*InstallerResultResponse)(nil), // 91: daemon.InstallerResultResponse + (*ExposeServiceRequest)(nil), // 92: daemon.ExposeServiceRequest + (*ExposeServiceEvent)(nil), // 93: daemon.ExposeServiceEvent + (*ExposeServiceReady)(nil), // 94: daemon.ExposeServiceReady + nil, // 95: daemon.Network.ResolvedIPsEntry + (*PortInfo_Range)(nil), // 96: daemon.PortInfo.Range + nil, // 97: daemon.SystemEvent.MetadataEntry + (*durationpb.Duration)(nil), // 98: google.protobuf.Duration + (*timestamppb.Timestamp)(nil), // 99: google.protobuf.Timestamp } var file_daemon_proto_depIdxs = []int32{ - 1, // 0: daemon.OSLifecycleRequest.type:type_name -> daemon.OSLifecycleRequest.CycleType - 92, // 1: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration - 27, // 2: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus - 93, // 3: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp - 93, // 4: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp - 92, // 5: daemon.PeerState.latency:type_name -> google.protobuf.Duration - 25, // 6: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo - 22, // 7: daemon.FullStatus.managementState:type_name -> daemon.ManagementState - 21, // 8: daemon.FullStatus.signalState:type_name -> daemon.SignalState - 20, // 9: daemon.FullStatus.localPeerState:type_name -> daemon.LocalPeerState - 19, // 10: daemon.FullStatus.peers:type_name -> daemon.PeerState - 23, // 11: daemon.FullStatus.relays:type_name -> daemon.RelayState - 24, // 12: daemon.FullStatus.dns_servers:type_name -> daemon.NSGroupState - 57, // 13: daemon.FullStatus.events:type_name -> daemon.SystemEvent - 26, // 14: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState - 33, // 15: daemon.ListNetworksResponse.routes:type_name -> daemon.Network - 89, // 16: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry - 90, // 17: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range - 34, // 18: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo - 34, // 19: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo - 35, // 20: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule + 2, // 0: daemon.OSLifecycleRequest.type:type_name -> daemon.OSLifecycleRequest.CycleType + 98, // 1: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration + 28, // 2: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus + 99, // 3: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp + 99, // 4: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp + 98, // 5: daemon.PeerState.latency:type_name -> google.protobuf.Duration + 26, // 6: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo + 23, // 7: daemon.FullStatus.managementState:type_name -> daemon.ManagementState + 22, // 8: daemon.FullStatus.signalState:type_name -> daemon.SignalState + 21, // 9: daemon.FullStatus.localPeerState:type_name -> daemon.LocalPeerState + 20, // 10: daemon.FullStatus.peers:type_name -> daemon.PeerState + 24, // 11: daemon.FullStatus.relays:type_name -> daemon.RelayState + 25, // 12: daemon.FullStatus.dns_servers:type_name -> daemon.NSGroupState + 58, // 13: daemon.FullStatus.events:type_name -> daemon.SystemEvent + 27, // 14: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState + 34, // 15: daemon.ListNetworksResponse.routes:type_name -> daemon.Network + 95, // 16: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry + 96, // 17: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range + 35, // 18: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo + 35, // 19: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo + 36, // 20: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule 0, // 21: daemon.GetLogLevelResponse.level:type_name -> daemon.LogLevel 0, // 22: daemon.SetLogLevelRequest.level:type_name -> daemon.LogLevel - 43, // 23: daemon.ListStatesResponse.states:type_name -> daemon.State - 52, // 24: daemon.TracePacketRequest.tcp_flags:type_name -> daemon.TCPFlags - 54, // 25: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage - 2, // 26: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity - 3, // 27: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category - 93, // 28: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp - 91, // 29: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry - 57, // 30: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent - 92, // 31: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration - 70, // 32: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile - 32, // 33: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList - 7, // 34: daemon.DaemonService.Login:input_type -> daemon.LoginRequest - 9, // 35: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest - 11, // 36: daemon.DaemonService.Up:input_type -> daemon.UpRequest - 13, // 37: daemon.DaemonService.Status:input_type -> daemon.StatusRequest - 15, // 38: daemon.DaemonService.Down:input_type -> daemon.DownRequest - 17, // 39: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest - 28, // 40: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest - 30, // 41: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest - 30, // 42: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest - 4, // 43: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest - 37, // 44: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest - 39, // 45: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest - 41, // 46: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest - 44, // 47: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest - 46, // 48: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest - 48, // 49: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest - 50, // 50: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest - 53, // 51: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest - 56, // 52: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest - 58, // 53: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest - 60, // 54: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest - 62, // 55: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest - 64, // 56: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest - 66, // 57: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest - 68, // 58: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest - 71, // 59: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest - 73, // 60: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest - 75, // 61: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest - 77, // 62: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest - 79, // 63: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest - 81, // 64: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest - 83, // 65: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest - 85, // 66: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest - 5, // 67: daemon.DaemonService.NotifyOSLifecycle:input_type -> daemon.OSLifecycleRequest - 87, // 68: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest - 8, // 69: daemon.DaemonService.Login:output_type -> daemon.LoginResponse - 10, // 70: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse - 12, // 71: daemon.DaemonService.Up:output_type -> daemon.UpResponse - 14, // 72: daemon.DaemonService.Status:output_type -> daemon.StatusResponse - 16, // 73: daemon.DaemonService.Down:output_type -> daemon.DownResponse - 18, // 74: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse - 29, // 75: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse - 31, // 76: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse - 31, // 77: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse - 36, // 78: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse - 38, // 79: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse - 40, // 80: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse - 42, // 81: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse - 45, // 82: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse - 47, // 83: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse - 49, // 84: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse - 51, // 85: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse - 55, // 86: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse - 57, // 87: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent - 59, // 88: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse - 61, // 89: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse - 63, // 90: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse - 65, // 91: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse - 67, // 92: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse - 69, // 93: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse - 72, // 94: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse - 74, // 95: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse - 76, // 96: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse - 78, // 97: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse - 80, // 98: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse - 82, // 99: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse - 84, // 100: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse - 86, // 101: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse - 6, // 102: daemon.DaemonService.NotifyOSLifecycle:output_type -> daemon.OSLifecycleResponse - 88, // 103: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse - 69, // [69:104] is the sub-list for method output_type - 34, // [34:69] is the sub-list for method input_type - 34, // [34:34] is the sub-list for extension type_name - 34, // [34:34] is the sub-list for extension extendee - 0, // [0:34] is the sub-list for field type_name + 44, // 23: daemon.ListStatesResponse.states:type_name -> daemon.State + 53, // 24: daemon.TracePacketRequest.tcp_flags:type_name -> daemon.TCPFlags + 55, // 25: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage + 3, // 26: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity + 4, // 27: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category + 99, // 28: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp + 97, // 29: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry + 58, // 30: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent + 98, // 31: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration + 71, // 32: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile + 1, // 33: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol + 94, // 34: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady + 33, // 35: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList + 8, // 36: daemon.DaemonService.Login:input_type -> daemon.LoginRequest + 10, // 37: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest + 12, // 38: daemon.DaemonService.Up:input_type -> daemon.UpRequest + 14, // 39: daemon.DaemonService.Status:input_type -> daemon.StatusRequest + 16, // 40: daemon.DaemonService.Down:input_type -> daemon.DownRequest + 18, // 41: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest + 29, // 42: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest + 31, // 43: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest + 31, // 44: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest + 5, // 45: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest + 38, // 46: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest + 40, // 47: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest + 42, // 48: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest + 45, // 49: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest + 47, // 50: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest + 49, // 51: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest + 51, // 52: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest + 54, // 53: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest + 57, // 54: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest + 59, // 55: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest + 61, // 56: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest + 63, // 57: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest + 65, // 58: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest + 67, // 59: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest + 69, // 60: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest + 72, // 61: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest + 74, // 62: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest + 76, // 63: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest + 78, // 64: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest + 80, // 65: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest + 82, // 66: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest + 84, // 67: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest + 86, // 68: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest + 88, // 69: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest + 6, // 70: daemon.DaemonService.NotifyOSLifecycle:input_type -> daemon.OSLifecycleRequest + 90, // 71: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest + 92, // 72: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest + 9, // 73: daemon.DaemonService.Login:output_type -> daemon.LoginResponse + 11, // 74: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse + 13, // 75: daemon.DaemonService.Up:output_type -> daemon.UpResponse + 15, // 76: daemon.DaemonService.Status:output_type -> daemon.StatusResponse + 17, // 77: daemon.DaemonService.Down:output_type -> daemon.DownResponse + 19, // 78: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse + 30, // 79: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse + 32, // 80: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse + 32, // 81: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse + 37, // 82: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse + 39, // 83: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse + 41, // 84: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse + 43, // 85: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse + 46, // 86: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse + 48, // 87: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse + 50, // 88: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse + 52, // 89: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse + 56, // 90: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse + 58, // 91: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent + 60, // 92: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse + 62, // 93: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse + 64, // 94: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse + 66, // 95: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse + 68, // 96: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse + 70, // 97: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse + 73, // 98: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse + 75, // 99: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse + 77, // 100: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse + 79, // 101: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse + 81, // 102: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse + 83, // 103: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse + 85, // 104: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse + 87, // 105: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse + 89, // 106: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse + 7, // 107: daemon.DaemonService.NotifyOSLifecycle:output_type -> daemon.OSLifecycleResponse + 91, // 108: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse + 93, // 109: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent + 73, // [73:110] is the sub-list for method output_type + 36, // [36:73] is the sub-list for method input_type + 36, // [36:36] is the sub-list for extension type_name + 36, // [36:36] is the sub-list for extension extendee + 0, // [0:36] is the sub-list for field type_name } func init() { file_daemon_proto_init() } @@ -6438,14 +6851,17 @@ func file_daemon_proto_init() { file_daemon_proto_msgTypes[56].OneofWrappers = []any{} file_daemon_proto_msgTypes[58].OneofWrappers = []any{} file_daemon_proto_msgTypes[69].OneofWrappers = []any{} - file_daemon_proto_msgTypes[75].OneofWrappers = []any{} + file_daemon_proto_msgTypes[77].OneofWrappers = []any{} + file_daemon_proto_msgTypes[88].OneofWrappers = []any{ + (*ExposeServiceEvent_Ready)(nil), + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc)), - NumEnums: 4, - NumMessages: 88, + NumEnums: 5, + NumMessages: 93, NumExtensions: 0, NumServices: 1, }, diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index 68b9a9348..89302c8c3 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -85,6 +85,10 @@ service DaemonService { rpc GetFeatures(GetFeaturesRequest) returns (GetFeaturesResponse) {} + // TriggerUpdate initiates installation of the pending enforced version. + // Called when the user clicks the install button in the UI (Mode 2 / enforced update). + rpc TriggerUpdate(TriggerUpdateRequest) returns (TriggerUpdateResponse) {} + // GetPeerSSHHostKey retrieves SSH host key for a specific peer rpc GetPeerSSHHostKey(GetPeerSSHHostKeyRequest) returns (GetPeerSSHHostKeyResponse) {} @@ -103,6 +107,9 @@ service DaemonService { rpc NotifyOSLifecycle(OSLifecycleRequest) returns(OSLifecycleResponse) {} rpc GetInstallerResult(InstallerResultRequest) returns (InstallerResultResponse) {} + + // ExposeService exposes a local port via the NetBird reverse proxy + rpc ExposeService(ExposeServiceRequest) returns (stream ExposeServiceEvent) {} } @@ -223,7 +230,7 @@ message WaitSSOLoginResponse { message UpRequest { optional string profileName = 1; optional string username = 2; - optional bool autoUpdate = 3; + reserved 3; } message UpResponse {} @@ -722,6 +729,13 @@ message GetFeaturesResponse{ bool disable_update_settings = 2; } +message TriggerUpdateRequest {} + +message TriggerUpdateResponse { + bool success = 1; + string errorMsg = 2; +} + // GetPeerSSHHostKeyRequest for retrieving SSH host key for a specific peer message GetPeerSSHHostKeyRequest { // peer IP address or FQDN to get SSH host key for @@ -801,3 +815,35 @@ message InstallerResultResponse { bool success = 1; string errorMsg = 2; } + +enum ExposeProtocol { + EXPOSE_HTTP = 0; + EXPOSE_HTTPS = 1; + EXPOSE_TCP = 2; + EXPOSE_UDP = 3; + EXPOSE_TLS = 4; +} + +message ExposeServiceRequest { + uint32 port = 1; + ExposeProtocol protocol = 2; + string pin = 3; + string password = 4; + repeated string user_groups = 5; + string domain = 6; + string name_prefix = 7; + uint32 listen_port = 8; +} + +message ExposeServiceEvent { + oneof event { + ExposeServiceReady ready = 1; + } +} + +message ExposeServiceReady { + string service_name = 1; + string service_url = 2; + string domain = 3; + bool port_auto_assigned = 4; +} diff --git a/client/proto/daemon_grpc.pb.go b/client/proto/daemon_grpc.pb.go index ea9b4df05..e5bd89597 100644 --- a/client/proto/daemon_grpc.pb.go +++ b/client/proto/daemon_grpc.pb.go @@ -64,6 +64,9 @@ type DaemonServiceClient interface { // Logout disconnects from the network and deletes the peer from the management server Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*LogoutResponse, error) GetFeatures(ctx context.Context, in *GetFeaturesRequest, opts ...grpc.CallOption) (*GetFeaturesResponse, error) + // TriggerUpdate initiates installation of the pending enforced version. + // Called when the user clicks the install button in the UI (Mode 2 / enforced update). + TriggerUpdate(ctx context.Context, in *TriggerUpdateRequest, opts ...grpc.CallOption) (*TriggerUpdateResponse, error) // GetPeerSSHHostKey retrieves SSH host key for a specific peer GetPeerSSHHostKey(ctx context.Context, in *GetPeerSSHHostKeyRequest, opts ...grpc.CallOption) (*GetPeerSSHHostKeyResponse, error) // RequestJWTAuth initiates JWT authentication flow for SSH @@ -76,6 +79,8 @@ type DaemonServiceClient interface { StopCPUProfile(ctx context.Context, in *StopCPUProfileRequest, opts ...grpc.CallOption) (*StopCPUProfileResponse, error) NotifyOSLifecycle(ctx context.Context, in *OSLifecycleRequest, opts ...grpc.CallOption) (*OSLifecycleResponse, error) GetInstallerResult(ctx context.Context, in *InstallerResultRequest, opts ...grpc.CallOption) (*InstallerResultResponse, error) + // ExposeService exposes a local port via the NetBird reverse proxy + ExposeService(ctx context.Context, in *ExposeServiceRequest, opts ...grpc.CallOption) (DaemonService_ExposeServiceClient, error) } type daemonServiceClient struct { @@ -361,6 +366,15 @@ func (c *daemonServiceClient) GetFeatures(ctx context.Context, in *GetFeaturesRe return out, nil } +func (c *daemonServiceClient) TriggerUpdate(ctx context.Context, in *TriggerUpdateRequest, opts ...grpc.CallOption) (*TriggerUpdateResponse, error) { + out := new(TriggerUpdateResponse) + err := c.cc.Invoke(ctx, "/daemon.DaemonService/TriggerUpdate", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *daemonServiceClient) GetPeerSSHHostKey(ctx context.Context, in *GetPeerSSHHostKeyRequest, opts ...grpc.CallOption) (*GetPeerSSHHostKeyResponse, error) { out := new(GetPeerSSHHostKeyResponse) err := c.cc.Invoke(ctx, "/daemon.DaemonService/GetPeerSSHHostKey", in, out, opts...) @@ -424,6 +438,38 @@ func (c *daemonServiceClient) GetInstallerResult(ctx context.Context, in *Instal return out, nil } +func (c *daemonServiceClient) ExposeService(ctx context.Context, in *ExposeServiceRequest, opts ...grpc.CallOption) (DaemonService_ExposeServiceClient, error) { + stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[1], "/daemon.DaemonService/ExposeService", opts...) + if err != nil { + return nil, err + } + x := &daemonServiceExposeServiceClient{stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +type DaemonService_ExposeServiceClient interface { + Recv() (*ExposeServiceEvent, error) + grpc.ClientStream +} + +type daemonServiceExposeServiceClient struct { + grpc.ClientStream +} + +func (x *daemonServiceExposeServiceClient) Recv() (*ExposeServiceEvent, error) { + m := new(ExposeServiceEvent) + if err := x.ClientStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} + // DaemonServiceServer is the server API for DaemonService service. // All implementations must embed UnimplementedDaemonServiceServer // for forward compatibility @@ -474,6 +520,9 @@ type DaemonServiceServer interface { // Logout disconnects from the network and deletes the peer from the management server Logout(context.Context, *LogoutRequest) (*LogoutResponse, error) GetFeatures(context.Context, *GetFeaturesRequest) (*GetFeaturesResponse, error) + // TriggerUpdate initiates installation of the pending enforced version. + // Called when the user clicks the install button in the UI (Mode 2 / enforced update). + TriggerUpdate(context.Context, *TriggerUpdateRequest) (*TriggerUpdateResponse, error) // GetPeerSSHHostKey retrieves SSH host key for a specific peer GetPeerSSHHostKey(context.Context, *GetPeerSSHHostKeyRequest) (*GetPeerSSHHostKeyResponse, error) // RequestJWTAuth initiates JWT authentication flow for SSH @@ -486,6 +535,8 @@ type DaemonServiceServer interface { StopCPUProfile(context.Context, *StopCPUProfileRequest) (*StopCPUProfileResponse, error) NotifyOSLifecycle(context.Context, *OSLifecycleRequest) (*OSLifecycleResponse, error) GetInstallerResult(context.Context, *InstallerResultRequest) (*InstallerResultResponse, error) + // ExposeService exposes a local port via the NetBird reverse proxy + ExposeService(*ExposeServiceRequest, DaemonService_ExposeServiceServer) error mustEmbedUnimplementedDaemonServiceServer() } @@ -577,6 +628,9 @@ func (UnimplementedDaemonServiceServer) Logout(context.Context, *LogoutRequest) func (UnimplementedDaemonServiceServer) GetFeatures(context.Context, *GetFeaturesRequest) (*GetFeaturesResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method GetFeatures not implemented") } +func (UnimplementedDaemonServiceServer) TriggerUpdate(context.Context, *TriggerUpdateRequest) (*TriggerUpdateResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method TriggerUpdate not implemented") +} func (UnimplementedDaemonServiceServer) GetPeerSSHHostKey(context.Context, *GetPeerSSHHostKeyRequest) (*GetPeerSSHHostKeyResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method GetPeerSSHHostKey not implemented") } @@ -598,6 +652,9 @@ func (UnimplementedDaemonServiceServer) NotifyOSLifecycle(context.Context, *OSLi func (UnimplementedDaemonServiceServer) GetInstallerResult(context.Context, *InstallerResultRequest) (*InstallerResultResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method GetInstallerResult not implemented") } +func (UnimplementedDaemonServiceServer) ExposeService(*ExposeServiceRequest, DaemonService_ExposeServiceServer) error { + return status.Errorf(codes.Unimplemented, "method ExposeService not implemented") +} func (UnimplementedDaemonServiceServer) mustEmbedUnimplementedDaemonServiceServer() {} // UnsafeDaemonServiceServer may be embedded to opt out of forward compatibility for this service. @@ -1118,6 +1175,24 @@ func _DaemonService_GetFeatures_Handler(srv interface{}, ctx context.Context, de return interceptor(ctx, in, info, handler) } +func _DaemonService_TriggerUpdate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(TriggerUpdateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).TriggerUpdate(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/daemon.DaemonService/TriggerUpdate", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).TriggerUpdate(ctx, req.(*TriggerUpdateRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _DaemonService_GetPeerSSHHostKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(GetPeerSSHHostKeyRequest) if err := dec(in); err != nil { @@ -1244,6 +1319,27 @@ func _DaemonService_GetInstallerResult_Handler(srv interface{}, ctx context.Cont return interceptor(ctx, in, info, handler) } +func _DaemonService_ExposeService_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(ExposeServiceRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(DaemonServiceServer).ExposeService(m, &daemonServiceExposeServiceServer{stream}) +} + +type DaemonService_ExposeServiceServer interface { + Send(*ExposeServiceEvent) error + grpc.ServerStream +} + +type daemonServiceExposeServiceServer struct { + grpc.ServerStream +} + +func (x *daemonServiceExposeServiceServer) Send(m *ExposeServiceEvent) error { + return x.ServerStream.SendMsg(m) +} + // DaemonService_ServiceDesc is the grpc.ServiceDesc for DaemonService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -1359,6 +1455,10 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetFeatures", Handler: _DaemonService_GetFeatures_Handler, }, + { + MethodName: "TriggerUpdate", + Handler: _DaemonService_TriggerUpdate_Handler, + }, { MethodName: "GetPeerSSHHostKey", Handler: _DaemonService_GetPeerSSHHostKey_Handler, @@ -1394,6 +1494,11 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{ Handler: _DaemonService_SubscribeEvents_Handler, ServerStreams: true, }, + { + StreamName: "ExposeService", + Handler: _DaemonService_ExposeService_Handler, + ServerStreams: true, + }, }, Metadata: "daemon.proto", } diff --git a/client/server/event.go b/client/server/event.go index b5c12a3a6..d93151c96 100644 --- a/client/server/event.go +++ b/client/server/event.go @@ -14,6 +14,7 @@ func (s *Server) SubscribeEvents(req *proto.SubscribeRequest, stream proto.Daemo }() log.Debug("client subscribed to events") + s.startUpdateManagerForGUI() for { select { diff --git a/client/server/lifecycle.go b/client/server/lifecycle.go deleted file mode 100644 index 3722c027d..000000000 --- a/client/server/lifecycle.go +++ /dev/null @@ -1,77 +0,0 @@ -package server - -import ( - "context" - - log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/internal" - "github.com/netbirdio/netbird/client/proto" -) - -// NotifyOSLifecycle handles operating system lifecycle events by executing appropriate logic based on the request type. -func (s *Server) NotifyOSLifecycle(callerCtx context.Context, req *proto.OSLifecycleRequest) (*proto.OSLifecycleResponse, error) { - switch req.GetType() { - case proto.OSLifecycleRequest_WAKEUP: - return s.handleWakeUp(callerCtx) - case proto.OSLifecycleRequest_SLEEP: - return s.handleSleep(callerCtx) - default: - log.Errorf("unknown OSLifecycleRequest type: %v", req.GetType()) - } - return &proto.OSLifecycleResponse{}, nil -} - -// handleWakeUp processes a wake-up event by triggering the Up command if the system was previously put to sleep. -// It resets the sleep state and logs the process. Returns a response or an error if the Up command fails. -func (s *Server) handleWakeUp(callerCtx context.Context) (*proto.OSLifecycleResponse, error) { - if !s.sleepTriggeredDown.Load() { - log.Info("skipping up because wasn't sleep down") - return &proto.OSLifecycleResponse{}, nil - } - - // avoid other wakeup runs if sleep didn't make the computer sleep - s.sleepTriggeredDown.Store(false) - - log.Info("running up after wake up") - _, err := s.Up(callerCtx, &proto.UpRequest{}) - if err != nil { - log.Errorf("running up failed: %v", err) - return &proto.OSLifecycleResponse{}, err - } - - log.Info("running up command executed successfully") - return &proto.OSLifecycleResponse{}, nil -} - -// handleSleep handles the sleep event by initiating a "down" sequence if the system is in a connected or connecting state. -func (s *Server) handleSleep(callerCtx context.Context) (*proto.OSLifecycleResponse, error) { - s.mutex.Lock() - - state := internal.CtxGetState(s.rootCtx) - status, err := state.Status() - if err != nil { - s.mutex.Unlock() - return &proto.OSLifecycleResponse{}, err - } - - if status != internal.StatusConnecting && status != internal.StatusConnected { - log.Infof("skipping setting the agent down because status is %s", status) - s.mutex.Unlock() - return &proto.OSLifecycleResponse{}, nil - } - s.mutex.Unlock() - - log.Info("running down after system started sleeping") - - _, err = s.Down(callerCtx, &proto.DownRequest{}) - if err != nil { - log.Errorf("running down failed: %v", err) - return &proto.OSLifecycleResponse{}, err - } - - s.sleepTriggeredDown.Store(true) - - log.Info("running down executed successfully") - return &proto.OSLifecycleResponse{}, nil -} diff --git a/client/server/lifecycle_test.go b/client/server/lifecycle_test.go deleted file mode 100644 index a604c60af..000000000 --- a/client/server/lifecycle_test.go +++ /dev/null @@ -1,219 +0,0 @@ -package server - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/netbirdio/netbird/client/internal" - "github.com/netbirdio/netbird/client/internal/peer" - "github.com/netbirdio/netbird/client/proto" -) - -func newTestServer() *Server { - ctx := internal.CtxInitState(context.Background()) - return &Server{ - rootCtx: ctx, - statusRecorder: peer.NewRecorder(""), - } -} - -func TestNotifyOSLifecycle_WakeUp_SkipsWhenNotSleepTriggered(t *testing.T) { - s := newTestServer() - - // sleepTriggeredDown is false by default - assert.False(t, s.sleepTriggeredDown.Load()) - - resp, err := s.NotifyOSLifecycle(context.Background(), &proto.OSLifecycleRequest{ - Type: proto.OSLifecycleRequest_WAKEUP, - }) - - require.NoError(t, err) - require.NotNil(t, resp) - assert.False(t, s.sleepTriggeredDown.Load(), "flag should remain false") -} - -func TestNotifyOSLifecycle_Sleep_SkipsWhenStatusIdle(t *testing.T) { - s := newTestServer() - - state := internal.CtxGetState(s.rootCtx) - state.Set(internal.StatusIdle) - - resp, err := s.NotifyOSLifecycle(context.Background(), &proto.OSLifecycleRequest{ - Type: proto.OSLifecycleRequest_SLEEP, - }) - - require.NoError(t, err) - require.NotNil(t, resp) - assert.False(t, s.sleepTriggeredDown.Load(), "flag should remain false when status is Idle") -} - -func TestNotifyOSLifecycle_Sleep_SkipsWhenStatusNeedsLogin(t *testing.T) { - s := newTestServer() - - state := internal.CtxGetState(s.rootCtx) - state.Set(internal.StatusNeedsLogin) - - resp, err := s.NotifyOSLifecycle(context.Background(), &proto.OSLifecycleRequest{ - Type: proto.OSLifecycleRequest_SLEEP, - }) - - require.NoError(t, err) - require.NotNil(t, resp) - assert.False(t, s.sleepTriggeredDown.Load(), "flag should remain false when status is NeedsLogin") -} - -func TestNotifyOSLifecycle_Sleep_SetsFlag_WhenConnecting(t *testing.T) { - s := newTestServer() - - state := internal.CtxGetState(s.rootCtx) - state.Set(internal.StatusConnecting) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - s.actCancel = cancel - - resp, err := s.NotifyOSLifecycle(ctx, &proto.OSLifecycleRequest{ - Type: proto.OSLifecycleRequest_SLEEP, - }) - - require.NoError(t, err) - assert.NotNil(t, resp, "handleSleep returns not nil response on success") - assert.True(t, s.sleepTriggeredDown.Load(), "flag should be set after sleep when connecting") -} - -func TestNotifyOSLifecycle_Sleep_SetsFlag_WhenConnected(t *testing.T) { - s := newTestServer() - - state := internal.CtxGetState(s.rootCtx) - state.Set(internal.StatusConnected) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - s.actCancel = cancel - - resp, err := s.NotifyOSLifecycle(ctx, &proto.OSLifecycleRequest{ - Type: proto.OSLifecycleRequest_SLEEP, - }) - - require.NoError(t, err) - assert.NotNil(t, resp, "handleSleep returns not nil response on success") - assert.True(t, s.sleepTriggeredDown.Load(), "flag should be set after sleep when connected") -} - -func TestNotifyOSLifecycle_WakeUp_ResetsFlag(t *testing.T) { - s := newTestServer() - - // Manually set the flag to simulate prior sleep down - s.sleepTriggeredDown.Store(true) - - // WakeUp will try to call Up which fails without proper setup, but flag should reset first - _, _ = s.NotifyOSLifecycle(context.Background(), &proto.OSLifecycleRequest{ - Type: proto.OSLifecycleRequest_WAKEUP, - }) - - assert.False(t, s.sleepTriggeredDown.Load(), "flag should be reset after WakeUp attempt") -} - -func TestNotifyOSLifecycle_MultipleWakeUpCalls(t *testing.T) { - s := newTestServer() - - // First wakeup without prior sleep - should be no-op - resp, err := s.NotifyOSLifecycle(context.Background(), &proto.OSLifecycleRequest{ - Type: proto.OSLifecycleRequest_WAKEUP, - }) - require.NoError(t, err) - require.NotNil(t, resp) - assert.False(t, s.sleepTriggeredDown.Load()) - - // Simulate prior sleep - s.sleepTriggeredDown.Store(true) - - // First wakeup after sleep - should reset flag - _, _ = s.NotifyOSLifecycle(context.Background(), &proto.OSLifecycleRequest{ - Type: proto.OSLifecycleRequest_WAKEUP, - }) - assert.False(t, s.sleepTriggeredDown.Load()) - - // Second wakeup - should be no-op - resp, err = s.NotifyOSLifecycle(context.Background(), &proto.OSLifecycleRequest{ - Type: proto.OSLifecycleRequest_WAKEUP, - }) - require.NoError(t, err) - require.NotNil(t, resp) - assert.False(t, s.sleepTriggeredDown.Load()) -} - -func TestHandleWakeUp_SkipsWhenFlagFalse(t *testing.T) { - s := newTestServer() - - resp, err := s.handleWakeUp(context.Background()) - - require.NoError(t, err) - require.NotNil(t, resp) -} - -func TestHandleWakeUp_ResetsFlagBeforeUp(t *testing.T) { - s := newTestServer() - s.sleepTriggeredDown.Store(true) - - // Even if Up fails, flag should be reset - _, _ = s.handleWakeUp(context.Background()) - - assert.False(t, s.sleepTriggeredDown.Load(), "flag must be reset before calling Up") -} - -func TestHandleSleep_SkipsForNonActiveStates(t *testing.T) { - tests := []struct { - name string - status internal.StatusType - }{ - {"Idle", internal.StatusIdle}, - {"NeedsLogin", internal.StatusNeedsLogin}, - {"LoginFailed", internal.StatusLoginFailed}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - s := newTestServer() - state := internal.CtxGetState(s.rootCtx) - state.Set(tt.status) - - resp, err := s.handleSleep(context.Background()) - - require.NoError(t, err) - require.NotNil(t, resp) - assert.False(t, s.sleepTriggeredDown.Load()) - }) - } -} - -func TestHandleSleep_ProceedsForActiveStates(t *testing.T) { - tests := []struct { - name string - status internal.StatusType - }{ - {"Connecting", internal.StatusConnecting}, - {"Connected", internal.StatusConnected}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - s := newTestServer() - state := internal.CtxGetState(s.rootCtx) - state.Set(tt.status) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - s.actCancel = cancel - - resp, err := s.handleSleep(ctx) - - require.NoError(t, err) - assert.NotNil(t, resp) - assert.True(t, s.sleepTriggeredDown.Load()) - }) - } -} diff --git a/client/server/server.go b/client/server/server.go index 108eab9fe..7c1e70692 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -21,13 +21,17 @@ import ( gstatus "google.golang.org/grpc/status" "github.com/netbirdio/netbird/client/internal/auth" + "github.com/netbirdio/netbird/client/internal/expose" "github.com/netbirdio/netbird/client/internal/profilemanager" + sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler" "github.com/netbirdio/netbird/client/system" mgm "github.com/netbirdio/netbird/shared/management/client" "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/statemanager" + "github.com/netbirdio/netbird/client/internal/updater" "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/version" ) @@ -85,8 +89,9 @@ type Server struct { profilesDisabled bool updateSettingsDisabled bool - // sleepTriggeredDown holds a state indicated if the sleep handler triggered the last client down - sleepTriggeredDown atomic.Bool + sleepHandler *sleephandler.SleepHandler + + updateManager *updater.Manager jwtCache *jwtCache } @@ -100,7 +105,7 @@ type oauthAuthFlow struct { // New server instance constructor. func New(ctx context.Context, logFile string, configFile string, profilesDisabled bool, updateSettingsDisabled bool) *Server { - return &Server{ + s := &Server{ rootCtx: ctx, logFile: logFile, persistSyncResponse: true, @@ -110,6 +115,10 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable updateSettingsDisabled: updateSettingsDisabled, jwtCache: newJWTCache(), } + agent := &serverAgent{s} + s.sleepHandler = sleephandler.New(agent) + + return s } func (s *Server) Start() error { @@ -130,6 +139,12 @@ func (s *Server) Start() error { log.Warnf(errRestoreResidualState, err) } + if s.updateManager == nil { + stateMgr := statemanager.New(s.profileManager.GetStatePath()) + s.updateManager = updater.NewManager(s.statusRecorder, stateMgr) + s.updateManager.CheckUpdateSuccess(s.rootCtx) + } + // if current state contains any error, return it // in all other cases we can continue execution only if status is idle and up command was // not in the progress or already successfully established connection. @@ -187,14 +202,14 @@ func (s *Server) Start() error { s.clientRunning = true s.clientRunningChan = make(chan struct{}) s.clientGiveUpChan = make(chan struct{}) - go s.connectWithRetryRuns(ctx, config, s.statusRecorder, false, s.clientRunningChan, s.clientGiveUpChan) + go s.connectWithRetryRuns(ctx, config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan) return nil } // connectWithRetryRuns runs the client connection with a backoff strategy where we retry the operation as additional // mechanism to keep the client connected even when the connection is lost. // we cancel retry if the client receive a stop or down command, or if disable auto connect is configured. -func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profilemanager.Config, statusRecorder *peer.Status, doInitialAutoUpdate bool, runningChan chan struct{}, giveUpChan chan struct{}) { +func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profilemanager.Config, statusRecorder *peer.Status, runningChan chan struct{}, giveUpChan chan struct{}) { defer func() { s.mutex.Lock() s.clientRunning = false @@ -202,7 +217,7 @@ func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profil }() if s.config.DisableAutoConnect { - if err := s.connect(ctx, s.config, s.statusRecorder, doInitialAutoUpdate, runningChan); err != nil { + if err := s.connect(ctx, s.config, s.statusRecorder, runningChan); err != nil { log.Debugf("run client connection exited with error: %v", err) } log.Tracef("client connection exited") @@ -231,8 +246,7 @@ func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profil }() runOperation := func() error { - err := s.connect(ctx, profileConfig, statusRecorder, doInitialAutoUpdate, runningChan) - doInitialAutoUpdate = false + err := s.connect(ctx, profileConfig, statusRecorder, runningChan) if err != nil { log.Debugf("run client connection exited with error: %v. Will retry in the background", err) return err @@ -636,8 +650,6 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR return s.waitForUp(callerCtx) } - defer s.mutex.Unlock() - if err := restoreResidualState(callerCtx, s.profileManager.GetStatePath()); err != nil { log.Warnf(errRestoreResidualState, err) } @@ -649,10 +661,12 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR // not in the progress or already successfully established connection. status, err := state.Status() if err != nil { + s.mutex.Unlock() return nil, err } if status != internal.StatusIdle { + s.mutex.Unlock() return nil, fmt.Errorf("up already in progress: current status %s", status) } @@ -669,17 +683,20 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR s.actCancel = cancel if s.config == nil { + s.mutex.Unlock() return nil, fmt.Errorf("config is not defined, please call login command first") } activeProf, err := s.profileManager.GetActiveProfileState() if err != nil { + s.mutex.Unlock() log.Errorf("failed to get active profile state: %v", err) return nil, fmt.Errorf("failed to get active profile state: %w", err) } if msg != nil && msg.ProfileName != nil { if err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil { + s.mutex.Unlock() log.Errorf("failed to switch profile: %v", err) return nil, fmt.Errorf("failed to switch profile: %w", err) } @@ -687,6 +704,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR activeProf, err = s.profileManager.GetActiveProfileState() if err != nil { + s.mutex.Unlock() log.Errorf("failed to get active profile state: %v", err) return nil, fmt.Errorf("failed to get active profile state: %w", err) } @@ -695,6 +713,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR config, _, err := s.getConfig(activeProf) if err != nil { + s.mutex.Unlock() log.Errorf("failed to get active profile config: %v", err) return nil, fmt.Errorf("failed to get active profile config: %w", err) } @@ -707,12 +726,9 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR s.clientRunningChan = make(chan struct{}) s.clientGiveUpChan = make(chan struct{}) - var doAutoUpdate bool - if msg != nil && msg.AutoUpdate != nil && *msg.AutoUpdate { - doAutoUpdate = true - } - go s.connectWithRetryRuns(ctx, s.config, s.statusRecorder, doAutoUpdate, s.clientRunningChan, s.clientGiveUpChan) + go s.connectWithRetryRuns(ctx, s.config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan) + s.mutex.Unlock() return s.waitForUp(callerCtx) } @@ -838,14 +854,26 @@ func (s *Server) cleanupConnection() error { if s.actCancel == nil { return ErrServiceNotUp } + + // Capture the engine reference before cancelling the context. + // After actCancel(), the connectWithRetryRuns goroutine wakes up + // and sets connectClient.engine = nil, causing connectClient.Stop() + // to skip the engine shutdown entirely. + var engine *internal.Engine + if s.connectClient != nil { + engine = s.connectClient.Engine() + } + s.actCancel() if s.connectClient == nil { return nil } - if err := s.connectClient.Stop(); err != nil { - return err + if engine != nil { + if err := engine.Stop(); err != nil { + return err + } } s.connectClient = nil @@ -1312,6 +1340,61 @@ func (s *Server) WaitJWTToken( }, nil } +// ExposeService exposes a local port via the NetBird reverse proxy. +func (s *Server) ExposeService(req *proto.ExposeServiceRequest, srv proto.DaemonService_ExposeServiceServer) error { + s.mutex.Lock() + if !s.clientRunning { + s.mutex.Unlock() + return gstatus.Errorf(codes.FailedPrecondition, "client is not running, run 'netbird up' first") + } + connectClient := s.connectClient + s.mutex.Unlock() + + if connectClient == nil { + return gstatus.Errorf(codes.FailedPrecondition, "client not initialized") + } + + engine := connectClient.Engine() + if engine == nil { + return gstatus.Errorf(codes.FailedPrecondition, "engine not initialized") + } + + mgr := engine.GetExposeManager() + if mgr == nil { + return gstatus.Errorf(codes.Internal, "expose manager not available") + } + + ctx := srv.Context() + + exposeCtx, exposeCancel := context.WithTimeout(ctx, 30*time.Second) + defer exposeCancel() + + mgmReq := expose.NewRequest(req) + result, err := mgr.Expose(exposeCtx, *mgmReq) + if err != nil { + return err + } + + if err := srv.Send(&proto.ExposeServiceEvent{ + Event: &proto.ExposeServiceEvent_Ready{ + Ready: &proto.ExposeServiceReady{ + ServiceName: result.ServiceName, + ServiceUrl: result.ServiceURL, + Domain: result.Domain, + PortAutoAssigned: result.PortAutoAssigned, + }, + }, + }); err != nil { + return err + } + + err = mgr.KeepAlive(ctx, result.Domain) + if err != nil { + return err + } + return nil +} + func isUnixRunningDesktop() bool { if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" { return false @@ -1546,11 +1629,17 @@ func (s *Server) GetFeatures(ctx context.Context, msg *proto.GetFeaturesRequest) return features, nil } -func (s *Server) connect(ctx context.Context, config *profilemanager.Config, statusRecorder *peer.Status, doInitialAutoUpdate bool, runningChan chan struct{}) error { +func (s *Server) connect(ctx context.Context, config *profilemanager.Config, statusRecorder *peer.Status, runningChan chan struct{}) error { log.Tracef("running client connection") - s.connectClient = internal.NewConnectClient(ctx, config, statusRecorder, doInitialAutoUpdate) - s.connectClient.SetSyncResponsePersistence(s.persistSyncResponse) - if err := s.connectClient.Run(runningChan, s.logFile); err != nil { + client := internal.NewConnectClient(ctx, config, statusRecorder) + client.SetUpdateManager(s.updateManager) + client.SetSyncResponsePersistence(s.persistSyncResponse) + + s.mutex.Lock() + s.connectClient = client + s.mutex.Unlock() + + if err := client.Run(runningChan, s.logFile); err != nil { return err } return nil @@ -1574,6 +1663,14 @@ func (s *Server) checkUpdateSettingsDisabled() bool { return false } +func (s *Server) startUpdateManagerForGUI() { + if s.updateManager == nil { + return + } + s.updateManager.Start(s.rootCtx) + s.updateManager.NotifyUI() +} + func (s *Server) onSessionExpire() { if runtime.GOOS != "windows" { isUIActive := internal.CheckUIApp() diff --git a/client/server/server_connect_test.go b/client/server/server_connect_test.go new file mode 100644 index 000000000..faea7da39 --- /dev/null +++ b/client/server/server_connect_test.go @@ -0,0 +1,187 @@ +package server + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/proto" +) + +func newTestServer() *Server { + return &Server{ + rootCtx: context.Background(), + statusRecorder: peer.NewRecorder(""), + } +} + +func newDummyConnectClient(ctx context.Context) *internal.ConnectClient { + return internal.NewConnectClient(ctx, nil, nil) +} + +// TestConnectSetsClientWithMutex validates that connect() sets s.connectClient +// under mutex protection so concurrent readers see a consistent value. +func TestConnectSetsClientWithMutex(t *testing.T) { + s := newTestServer() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Manually simulate what connect() does (without calling Run which panics without full setup) + client := newDummyConnectClient(ctx) + + s.mutex.Lock() + s.connectClient = client + s.mutex.Unlock() + + // Verify the assignment is visible under mutex + s.mutex.Lock() + assert.Equal(t, client, s.connectClient, "connectClient should be set") + s.mutex.Unlock() +} + +// TestConcurrentConnectClientAccess validates that concurrent reads of +// s.connectClient under mutex don't race with a write. +func TestConcurrentConnectClientAccess(t *testing.T) { + s := newTestServer() + ctx := context.Background() + client := newDummyConnectClient(ctx) + + var wg sync.WaitGroup + nilCount := 0 + setCount := 0 + var mu sync.Mutex + + // Start readers + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + s.mutex.Lock() + c := s.connectClient + s.mutex.Unlock() + + mu.Lock() + defer mu.Unlock() + if c == nil { + nilCount++ + } else { + setCount++ + } + }() + } + + // Simulate connect() writing under mutex + time.Sleep(5 * time.Millisecond) + s.mutex.Lock() + s.connectClient = client + s.mutex.Unlock() + + wg.Wait() + + assert.Equal(t, 50, nilCount+setCount, "all goroutines should complete without panic") +} + +// TestCleanupConnection_ClearsConnectClient validates that cleanupConnection +// properly nils out connectClient. +func TestCleanupConnection_ClearsConnectClient(t *testing.T) { + s := newTestServer() + _, cancel := context.WithCancel(context.Background()) + s.actCancel = cancel + + s.connectClient = newDummyConnectClient(context.Background()) + s.clientRunning = true + + err := s.cleanupConnection() + require.NoError(t, err) + + assert.Nil(t, s.connectClient, "connectClient should be nil after cleanup") +} + +// TestCleanState_NilConnectClient validates that CleanState doesn't panic +// when connectClient is nil. +func TestCleanState_NilConnectClient(t *testing.T) { + s := newTestServer() + s.connectClient = nil + s.profileManager = nil // will cause error if it tries to proceed past the nil check + + // Should not panic — the nil check should prevent calling Status() on nil + assert.NotPanics(t, func() { + _, _ = s.CleanState(context.Background(), &proto.CleanStateRequest{All: true}) + }) +} + +// TestDeleteState_NilConnectClient validates that DeleteState doesn't panic +// when connectClient is nil. +func TestDeleteState_NilConnectClient(t *testing.T) { + s := newTestServer() + s.connectClient = nil + s.profileManager = nil + + assert.NotPanics(t, func() { + _, _ = s.DeleteState(context.Background(), &proto.DeleteStateRequest{All: true}) + }) +} + +// TestDownThenUp_StaleRunningChan documents the known state issue where +// clientRunningChan from a previous connection is already closed, causing +// waitForUp() to return immediately on reconnect. +func TestDownThenUp_StaleRunningChan(t *testing.T) { + s := newTestServer() + + // Simulate state after a successful connection + s.clientRunning = true + s.clientRunningChan = make(chan struct{}) + close(s.clientRunningChan) // closed when engine started + s.clientGiveUpChan = make(chan struct{}) + s.connectClient = newDummyConnectClient(context.Background()) + + _, cancel := context.WithCancel(context.Background()) + s.actCancel = cancel + + // Simulate Down(): cleanupConnection sets connectClient = nil + s.mutex.Lock() + err := s.cleanupConnection() + s.mutex.Unlock() + require.NoError(t, err) + + // After cleanup: connectClient is nil, clientRunning still true + // (goroutine hasn't exited yet) + s.mutex.Lock() + assert.Nil(t, s.connectClient, "connectClient should be nil after cleanup") + assert.True(t, s.clientRunning, "clientRunning still true until goroutine exits") + s.mutex.Unlock() + + // waitForUp() returns immediately due to stale closed clientRunningChan + ctx, ctxCancel := context.WithTimeout(context.Background(), 2*time.Second) + defer ctxCancel() + + waitDone := make(chan error, 1) + go func() { + _, err := s.waitForUp(ctx) + waitDone <- err + }() + + select { + case err := <-waitDone: + assert.NoError(t, err, "waitForUp returns success on stale channel") + // But connectClient is still nil — this is the stale state issue + s.mutex.Lock() + assert.Nil(t, s.connectClient, "connectClient is nil despite waitForUp success") + s.mutex.Unlock() + case <-time.After(1 * time.Second): + t.Fatal("waitForUp should have returned immediately due to stale closed channel") + } +} + +// TestConnectClient_EngineNilOnFreshClient validates that a newly created +// ConnectClient has nil Engine (before Run is called). +func TestConnectClient_EngineNilOnFreshClient(t *testing.T) { + client := newDummyConnectClient(context.Background()) + assert.Nil(t, client.Engine(), "engine should be nil on fresh ConnectClient") +} diff --git a/client/server/server_test.go b/client/server/server_test.go index 82079c531..6de23d501 100644 --- a/client/server/server_test.go +++ b/client/server/server_test.go @@ -113,7 +113,7 @@ func TestConnectWithRetryRuns(t *testing.T) { t.Setenv(maxRetryTimeVar, "5s") t.Setenv(retryMultiplierVar, "1") - s.connectWithRetryRuns(ctx, config, s.statusRecorder, false, nil, nil) + s.connectWithRetryRuns(ctx, config, s.statusRecorder, nil, nil) if counter < 3 { t.Fatalf("expected counter > 2, got %d", counter) } diff --git a/client/server/sleep.go b/client/server/sleep.go new file mode 100644 index 000000000..7a83c75a6 --- /dev/null +++ b/client/server/sleep.go @@ -0,0 +1,46 @@ +package server + +import ( + "context" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/client/proto" +) + +// serverAgent adapts Server to the handler.Agent and handler.StatusChecker interfaces +type serverAgent struct { + s *Server +} + +func (a *serverAgent) Up(ctx context.Context) error { + _, err := a.s.Up(ctx, &proto.UpRequest{}) + return err +} + +func (a *serverAgent) Down(ctx context.Context) error { + _, err := a.s.Down(ctx, &proto.DownRequest{}) + return err +} + +func (a *serverAgent) Status() (internal.StatusType, error) { + return internal.CtxGetState(a.s.rootCtx).Status() +} + +// NotifyOSLifecycle handles operating system lifecycle events by executing appropriate logic based on the request type. +func (s *Server) NotifyOSLifecycle(callerCtx context.Context, req *proto.OSLifecycleRequest) (*proto.OSLifecycleResponse, error) { + switch req.GetType() { + case proto.OSLifecycleRequest_WAKEUP: + if err := s.sleepHandler.HandleWakeUp(callerCtx); err != nil { + return &proto.OSLifecycleResponse{}, err + } + case proto.OSLifecycleRequest_SLEEP: + if err := s.sleepHandler.HandleSleep(callerCtx); err != nil { + return &proto.OSLifecycleResponse{}, err + } + default: + log.Errorf("unknown OSLifecycleRequest type: %v", req.GetType()) + } + return &proto.OSLifecycleResponse{}, nil +} diff --git a/client/server/state.go b/client/server/state.go index 1cf85cd37..8dca6bde1 100644 --- a/client/server/state.go +++ b/client/server/state.go @@ -39,7 +39,7 @@ func (s *Server) ListStates(_ context.Context, _ *proto.ListStatesRequest) (*pro // CleanState handles cleaning of states (performing cleanup operations) func (s *Server) CleanState(ctx context.Context, req *proto.CleanStateRequest) (*proto.CleanStateResponse, error) { - if s.connectClient.Status() == internal.StatusConnected || s.connectClient.Status() == internal.StatusConnecting { + if s.connectClient != nil && (s.connectClient.Status() == internal.StatusConnected || s.connectClient.Status() == internal.StatusConnecting) { return nil, status.Errorf(codes.FailedPrecondition, "cannot clean state while connecting or connected, run 'netbird down' first.") } @@ -82,7 +82,7 @@ func (s *Server) CleanState(ctx context.Context, req *proto.CleanStateRequest) ( // DeleteState handles deletion of states without cleanup func (s *Server) DeleteState(ctx context.Context, req *proto.DeleteStateRequest) (*proto.DeleteStateResponse, error) { - if s.connectClient.Status() == internal.StatusConnected || s.connectClient.Status() == internal.StatusConnecting { + if s.connectClient != nil && (s.connectClient.Status() == internal.StatusConnected || s.connectClient.Status() == internal.StatusConnecting) { return nil, status.Errorf(codes.FailedPrecondition, "cannot clean state while connecting or connected, run 'netbird down' first.") } diff --git a/client/server/triggerupdate.go b/client/server/triggerupdate.go new file mode 100644 index 000000000..ffcb527e7 --- /dev/null +++ b/client/server/triggerupdate.go @@ -0,0 +1,24 @@ +package server + +import ( + "context" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/proto" +) + +// TriggerUpdate initiates installation of the pending enforced version. +// It is called when the user clicks the install button in the UI (Mode 2 / enforced update). +func (s *Server) TriggerUpdate(ctx context.Context, _ *proto.TriggerUpdateRequest) (*proto.TriggerUpdateResponse, error) { + if s.updateManager == nil { + return &proto.TriggerUpdateResponse{Success: false, ErrorMsg: "update manager not available"}, nil + } + + if err := s.updateManager.Install(ctx); err != nil { + log.Warnf("TriggerUpdate failed: %v", err) + return &proto.TriggerUpdateResponse{Success: false, ErrorMsg: err.Error()}, nil + } + + return &proto.TriggerUpdateResponse{Success: true}, nil +} diff --git a/client/server/updateresult.go b/client/server/updateresult.go index 8e00d5062..8d1ef0e5f 100644 --- a/client/server/updateresult.go +++ b/client/server/updateresult.go @@ -5,7 +5,7 @@ import ( log "github.com/sirupsen/logrus" - "github.com/netbirdio/netbird/client/internal/updatemanager/installer" + "github.com/netbirdio/netbird/client/internal/updater/installer" "github.com/netbirdio/netbird/client/proto" ) diff --git a/client/ssh/client/client.go b/client/ssh/client/client.go index 342da7303..7f72a72cf 100644 --- a/client/ssh/client/client.go +++ b/client/ssh/client/client.go @@ -19,6 +19,7 @@ import ( "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" @@ -268,7 +269,7 @@ func getDefaultDaemonAddr() string { if runtime.GOOS == "windows" { return DefaultDaemonAddrWindows } - return DefaultDaemonAddr + return daemonaddr.ResolveUnixDaemonAddr(DefaultDaemonAddr) } // DialOptions contains options for SSH connections diff --git a/client/ssh/server/getent_cgo_unix.go b/client/ssh/server/getent_cgo_unix.go new file mode 100644 index 000000000..4afbfc627 --- /dev/null +++ b/client/ssh/server/getent_cgo_unix.go @@ -0,0 +1,24 @@ +//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 new file mode 100644 index 000000000..314daae4c --- /dev/null +++ b/client/ssh/server/getent_nocgo_unix.go @@ -0,0 +1,74 @@ +//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_test.go b/client/ssh/server/getent_test.go new file mode 100644 index 000000000..5eac2fdbe --- /dev/null +++ b/client/ssh/server/getent_test.go @@ -0,0 +1,172 @@ +package server + +import ( + "os/user" + "runtime" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLookupWithGetent_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) + 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") + require.Error(t, err, "should fail for nonexistent user") +} + +func TestCurrentUserWithGetent(t *testing.T) { + stdUser, err := user.Current() + require.NoError(t, err) + + u, err := currentUserWithGetent() + require.NoError(t, err) + assert.Equal(t, stdUser.Uid, u.Uid) + assert.Equal(t, stdUser.Username, u.Username) +} + +func TestGroupIdsWithFallback_CurrentUser(t *testing.T) { + current, err := user.Current() + require.NoError(t, err) + + groups, err := groupIdsWithFallback(current) + require.NoError(t, err) + require.NotEmpty(t, groups, "current user should have at least one group") + + if runtime.GOOS != "windows" { + for _, gid := range groups { + _, err := strconv.ParseUint(gid, 10, 32) + assert.NoError(t, err, "group ID %q should be a valid uint32", gid) + } + } +} + +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 + } + + 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) + if shell == "" { + t.Log("getShellFromGetent returned empty, getent may not be available") + return + } + assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell) +} + +func TestLookupWithGetent_RootUser(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("no root user on Windows") + } + + u, err := lookupWithGetent("root") + if err != nil { + t.Skip("root user not available on this system") + } + assert.Equal(t, "0", u.Uid, "root should have UID 0") +} + +// 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. +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") + 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) + 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) + require.NoError(t, err) + require.NotEmpty(t, groups, "user must have at least one group") + + foundPrimary := false + for _, gid := range groups { + if runtime.GOOS != "windows" { + _, err := strconv.ParseUint(gid, 10, 32) + require.NoError(t, err, "group ID %q must be a valid uint32", gid) + } + if gid == current.Gid { + foundPrimary = true + } + } + 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. +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) + require.NoError(t, err) + + groups, err := groupIdsWithFallback(resolved) + require.NoError(t, err) + require.NotEmpty(t, groups, "resolved user must have groups") + + // On Unix, all returned GIDs must be valid numeric values. + // On Windows, group IDs are SIDs (e.g., "S-1-5-32-544"). + if runtime.GOOS != "windows" { + for _, gid := range groups { + _, err := strconv.ParseUint(gid, 10, 32) + assert.NoError(t, err, "group ID %q should be numeric", gid) + } + } +} + +// 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/ssh/server/getent_unix.go b/client/ssh/server/getent_unix.go new file mode 100644 index 000000000..18edb2fdf --- /dev/null +++ b/client/ssh/server/getent_unix.go @@ -0,0 +1,122 @@ +//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_unix_test.go b/client/ssh/server/getent_unix_test.go new file mode 100644 index 000000000..e44563b79 --- /dev/null +++ b/client/ssh/server/getent_unix_test.go @@ -0,0 +1,410 @@ +//go:build !windows + +package server + +import ( + "os/exec" + "os/user" + "runtime" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseGetentPasswd(t *testing.T) { + tests := []struct { + name string + input string + wantUser *user.User + wantShell string + wantErr bool + errContains string + }{ + { + name: "standard entry", + input: "alice:x:1001:1001:Alice Smith:/home/alice:/bin/bash\n", + wantUser: &user.User{ + Username: "alice", + Uid: "1001", + Gid: "1001", + Name: "Alice Smith", + HomeDir: "/home/alice", + }, + wantShell: "/bin/bash", + }, + { + name: "root entry", + input: "root:x:0:0:root:/root:/bin/bash", + wantUser: &user.User{ + Username: "root", + Uid: "0", + Gid: "0", + Name: "root", + HomeDir: "/root", + }, + wantShell: "/bin/bash", + }, + { + name: "empty gecos field", + input: "svc:x:999:999::/var/lib/svc:/usr/sbin/nologin", + wantUser: &user.User{ + Username: "svc", + Uid: "999", + Gid: "999", + Name: "", + HomeDir: "/var/lib/svc", + }, + wantShell: "/usr/sbin/nologin", + }, + { + name: "gecos with commas", + input: "john:x:1002:1002:John Doe,Room 101,555-1234,555-4321:/home/john:/bin/zsh", + wantUser: &user.User{ + Username: "john", + Uid: "1002", + Gid: "1002", + Name: "John Doe,Room 101,555-1234,555-4321", + HomeDir: "/home/john", + }, + wantShell: "/bin/zsh", + }, + { + name: "remote user with large UID", + input: "remoteuser:*:50001:50001:Remote User:/home/remoteuser:/bin/bash\n", + wantUser: &user.User{ + Username: "remoteuser", + Uid: "50001", + Gid: "50001", + Name: "Remote User", + HomeDir: "/home/remoteuser", + }, + wantShell: "/bin/bash", + }, + { + name: "no shell field (only 6 fields)", + input: "minimal:x:1000:1000::/home/minimal", + wantUser: &user.User{ + Username: "minimal", + Uid: "1000", + Gid: "1000", + Name: "", + HomeDir: "/home/minimal", + }, + wantShell: "", + }, + { + name: "too few fields", + input: "bad:x:1000", + wantErr: true, + errContains: "need 6+ fields", + }, + { + name: "empty username", + input: ":x:1000:1000::/home/test:/bin/bash", + wantErr: true, + errContains: "missing required fields", + }, + { + name: "empty UID", + input: "test:x::1000::/home/test:/bin/bash", + wantErr: true, + errContains: "missing required fields", + }, + { + name: "empty GID", + input: "test:x:1000:::/home/test:/bin/bash", + wantErr: true, + errContains: "missing required fields", + }, + { + name: "empty input", + input: "", + wantErr: true, + errContains: "need 6+ fields", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u, shell, err := parseGetentPasswd(tt.input) + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantUser.Username, u.Username, "username") + assert.Equal(t, tt.wantUser.Uid, u.Uid, "UID") + assert.Equal(t, tt.wantUser.Gid, u.Gid, "GID") + assert.Equal(t, tt.wantUser.Name, u.Name, "name/gecos") + assert.Equal(t, tt.wantUser.HomeDir, u.HomeDir, "home directory") + assert.Equal(t, tt.wantShell, shell, "shell") + }) + } +} + +func TestValidateGetentInput(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + {"normal username", "alice", true}, + {"numeric UID", "1001", true}, + {"dots and underscores", "alice.bob_test", true}, + {"hyphen", "alice-bob", true}, + {"kerberos principal", "user@REALM", true}, + {"samba machine account", "MACHINE$", true}, + {"NIS compat", "+user", true}, + {"empty", "", false}, + {"null byte", "alice\x00bob", false}, + {"newline", "alice\nbob", false}, + {"tab", "alice\tbob", false}, + {"control char", "alice\x01bob", false}, + {"DEL char", "alice\x7fbob", false}, + {"space rejected", "alice bob", false}, + {"semicolon rejected", "alice;bob", false}, + {"backtick rejected", "alice`bob", false}, + {"pipe rejected", "alice|bob", false}, + {"33 chars exceeds non-linux max", makeLongString(33), runtime.GOOS == "linux"}, + {"256 chars at linux max", makeLongString(256), runtime.GOOS == "linux"}, + {"257 chars exceeds all limits", makeLongString(257), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, validateGetentInput(tt.input)) + }) + } +} + +func makeLongString(n int) string { + b := make([]byte, n) + for i := range b { + b[i] = 'a' + } + return string(b) +} + +func TestRunGetent_RootUser(t *testing.T) { + if _, err := exec.LookPath("getent"); err != nil { + t.Skip("getent not available on this system") + } + + u, shell, err := runGetent("root") + require.NoError(t, err) + assert.Equal(t, "root", u.Username) + assert.Equal(t, "0", u.Uid) + assert.Equal(t, "0", u.Gid) + assert.NotEmpty(t, shell, "root should have a shell") +} + +func TestRunGetent_ByUID(t *testing.T) { + if _, err := exec.LookPath("getent"); err != nil { + t.Skip("getent not available on this system") + } + + u, _, err := runGetent("0") + require.NoError(t, err) + assert.Equal(t, "root", u.Username) + assert.Equal(t, "0", u.Uid) +} + +func TestRunGetent_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") + assert.Error(t, err) +} + +func TestRunGetent_InvalidInput(t *testing.T) { + _, _, err := runGetent("") + assert.Error(t, err) + + _, _, err = runGetent("user\x00name") + assert.Error(t, err) +} + +func TestRunGetent_NotAvailable(t *testing.T) { + if _, err := exec.LookPath("getent"); err == nil { + t.Skip("getent is available, can't test missing case") + } + + _, _, err := runGetent("root") + assert.Error(t, err, "should fail when getent is not installed") +} + +func TestRunIdGroups_CurrentUser(t *testing.T) { + if _, err := exec.LookPath("id"); err != nil { + t.Skip("id not available on this system") + } + + current, err := user.Current() + require.NoError(t, err) + + groups, err := runIdGroups(current.Username) + require.NoError(t, err) + require.NotEmpty(t, groups, "current user should have at least one group") + + for _, gid := range groups { + _, err := strconv.ParseUint(gid, 10, 32) + assert.NoError(t, err, "group ID %q should be a valid uint32", gid) + } +} + +func TestRunIdGroups_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") + assert.Error(t, err) +} + +func TestRunIdGroups_InvalidInput(t *testing.T) { + _, err := runIdGroups("") + assert.Error(t, err) + + _, err = runIdGroups("user\x00name") + assert.Error(t, err) +} + +func TestGetentResultsMatchStdlib(t *testing.T) { + if _, err := exec.LookPath("getent"); err != nil { + t.Skip("getent not available on this system") + } + + current, err := user.Current() + require.NoError(t, err) + + getentUser, _, err := runGetent(current.Username) + require.NoError(t, err) + + assert.Equal(t, current.Username, getentUser.Username, "username should match") + assert.Equal(t, current.Uid, getentUser.Uid, "UID should match") + assert.Equal(t, current.Gid, getentUser.Gid, "GID should match") + assert.Equal(t, current.HomeDir, getentUser.HomeDir, "home directory should match") +} + +func TestGetentResultsMatchStdlib_ByUID(t *testing.T) { + if _, err := exec.LookPath("getent"); err != nil { + t.Skip("getent not available on this system") + } + + current, err := user.Current() + require.NoError(t, err) + + getentUser, _, err := runGetent(current.Uid) + require.NoError(t, err) + + assert.Equal(t, current.Username, getentUser.Username, "username should match when looked up by UID") + assert.Equal(t, current.Uid, getentUser.Uid, "UID should match") +} + +func TestIdGroupsMatchStdlib(t *testing.T) { + if _, err := exec.LookPath("id"); err != nil { + t.Skip("id not available on this system") + } + + current, err := user.Current() + require.NoError(t, err) + + stdGroups, err := current.GroupIds() + if err != nil { + t.Skip("os/user.GroupIds() not working, likely CGO_ENABLED=0") + } + + idGroups, err := runIdGroups(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") +} + +func uniqueStrings(ss []string) []string { + seen := make(map[string]struct{}, len(ss)) + out := make([]string, 0, len(ss)) + for _, s := range ss { + if _, ok := seen[s]; ok { + continue + } + seen[s] = struct{}{} + out = append(out, s) + } + 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/ssh/server/getent_windows.go b/client/ssh/server/getent_windows.go new file mode 100644 index 000000000..3e76b3e8e --- /dev/null +++ b/client/ssh/server/getent_windows.go @@ -0,0 +1,26 @@ +//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/server.go b/client/ssh/server/server.go index 1ddb60f8e..4431ae423 100644 --- a/client/ssh/server/server.go +++ b/client/ssh/server/server.go @@ -46,8 +46,10 @@ const ( cmdSFTP = "" cmdNonInteractive = "" - // DefaultJWTMaxTokenAge is the default maximum age for JWT tokens accepted by the SSH server - DefaultJWTMaxTokenAge = 5 * 60 + // DefaultJWTMaxTokenAge is the default maximum age for JWT tokens accepted by the SSH server. + // Set to 10 minutes to accommodate identity providers like Azure Entra ID + // that backdate the iat claim by up to 5 minutes. + DefaultJWTMaxTokenAge = 10 * 60 ) var ( diff --git a/client/ssh/server/shell.go b/client/ssh/server/shell.go index fea9d2910..1e8ff5e31 100644 --- a/client/ssh/server/shell.go +++ b/client/ssh/server/shell.go @@ -49,10 +49,14 @@ func getWindowsUserShell() string { return `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe` } -// getUnixUserShell returns the shell for Unix-like systems +// getUnixUserShell returns the shell for Unix-like systems. +// Tries /etc/passwd first (fast, no subprocess), falls back to getent for NSS users. func getUnixUserShell(userID string) string { - shell := getShellFromPasswd(userID) - if shell != "" { + if shell := getShellFromPasswd(userID); shell != "" { + return shell + } + + if shell := getShellFromGetent(userID); shell != "" { return shell } diff --git a/client/ssh/server/user_utils.go b/client/ssh/server/user_utils.go index 799882cbb..bc2aa2d7d 100644 --- a/client/ssh/server/user_utils.go +++ b/client/ssh/server/user_utils.go @@ -23,8 +23,8 @@ func isPlatformUnix() bool { // Dependency injection variables for testing - allows mocking dynamic runtime checks var ( - getCurrentUser = user.Current - lookupUser = user.Lookup + getCurrentUser = currentUserWithGetent + lookupUser = lookupWithGetent getCurrentOS = func() string { return runtime.GOOS } getIsProcessPrivileged = isCurrentProcessPrivileged diff --git a/client/ssh/server/userswitching_unix.go b/client/ssh/server/userswitching_unix.go index d80b77042..220e2240f 100644 --- a/client/ssh/server/userswitching_unix.go +++ b/client/ssh/server/userswitching_unix.go @@ -146,32 +146,30 @@ func (s *Server) parseUserCredentials(localUser *user.User) (uint32, uint32, []u } gid := uint32(gid64) - groups, err := s.getSupplementaryGroups(localUser.Username) - if err != nil { - log.Warnf("failed to get supplementary groups for user %s: %v", localUser.Username, err) + groups, err := s.getSupplementaryGroups(localUser) + if err != nil || len(groups) == 0 { + if err != nil { + log.Warnf("failed to get supplementary groups for user %s: %v", localUser.Username, err) + } groups = []uint32{gid} } return uid, gid, groups, nil } -// getSupplementaryGroups retrieves supplementary group IDs for a user -func (s *Server) getSupplementaryGroups(username string) ([]uint32, error) { - u, err := user.Lookup(username) +// 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) if err != nil { - return nil, fmt.Errorf("lookup user %s: %w", username, err) - } - - groupIDStrings, err := u.GroupIds() - if err != nil { - return nil, fmt.Errorf("get group IDs for user %s: %w", username, err) + return nil, fmt.Errorf("get group IDs for user %s: %w", u.Username, err) } groups := make([]uint32, len(groupIDStrings)) for i, gidStr := range groupIDStrings { gid64, err := strconv.ParseUint(gidStr, 10, 32) if err != nil { - return nil, fmt.Errorf("invalid group ID %s for user %s: %w", gidStr, username, err) + return nil, fmt.Errorf("invalid group ID %s for user %s: %w", gidStr, u.Username, err) } groups[i] = uint32(gid64) } diff --git a/client/ui/client_ui.go b/client/ui/client_ui.go index 0290e17d5..0574e53d0 100644 --- a/client/ui/client_ui.go +++ b/client/ui/client_ui.go @@ -34,7 +34,6 @@ import ( "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" - protobuf "google.golang.org/protobuf/proto" "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/internal" @@ -308,10 +307,11 @@ type serviceClient struct { sshJWTCacheTTL int connected bool - update *version.Update daemonVersion string updateIndicationLock sync.Mutex isUpdateIconActive bool + isEnforcedUpdate bool + lastNotifiedVersion string settingsEnabled bool profilesEnabled bool showNetworks bool @@ -323,7 +323,7 @@ type serviceClient struct { exitNodeMu sync.Mutex mExitNodeItems []menuHandler - exitNodeStates []exitNodeState + exitNodeRetryCancel context.CancelFunc mExitNodeDeselectAll *systray.MenuItem logFile string wLoginURL fyne.Window @@ -367,7 +367,6 @@ func newServiceClient(args *newServiceClientArgs) *serviceClient { showAdvancedSettings: args.showSettings, showNetworks: args.showNetworks, - update: version.NewUpdateAndStart("nb/client-ui"), } s.eventHandler = newEventHandler(s) @@ -828,7 +827,7 @@ func (s *serviceClient) handleSSOLogin(ctx context.Context, loginResp *proto.Log return nil } -func (s *serviceClient) menuUpClick(ctx context.Context, wannaAutoUpdate bool) error { +func (s *serviceClient) menuUpClick(ctx context.Context) error { systray.SetTemplateIcon(iconConnectingMacOS, s.icConnecting) conn, err := s.getSrvClient(defaultFailTimeout) if err != nil { @@ -850,9 +849,7 @@ func (s *serviceClient) menuUpClick(ctx context.Context, wannaAutoUpdate bool) e return nil } - if _, err := s.conn.Up(s.ctx, &proto.UpRequest{ - AutoUpdate: protobuf.Bool(wannaAutoUpdate), - }); err != nil { + if _, err := s.conn.Up(s.ctx, &proto.UpRequest{}); err != nil { return fmt.Errorf("start connection: %w", err) } @@ -924,7 +921,7 @@ func (s *serviceClient) updateStatus() error { s.mDown.Enable() s.mNetworks.Enable() s.mExitNode.Enable() - go s.updateExitNodes() + s.startExitNodeRefresh() systrayIconState = true case status.Status == string(internal.StatusConnecting): s.setConnectingStatus() @@ -933,13 +930,13 @@ func (s *serviceClient) updateStatus() error { systrayIconState = false } - // the updater struct notify by the upgrades available only, but if meanwhile the daemon has successfully - // updated must reset the mUpdate visibility state + // if the daemon version changed (e.g. after a successful update), reset the update indication if s.daemonVersion != status.DaemonVersion { - s.mUpdate.Hide() + if s.daemonVersion != "" { + s.mUpdate.Hide() + s.isUpdateIconActive = false + } s.daemonVersion = status.DaemonVersion - - s.isUpdateIconActive = s.update.SetDaemonVersion(status.DaemonVersion) if !s.isUpdateIconActive { if systrayIconState { systray.SetTemplateIcon(iconConnectedMacOS, s.icConnected) @@ -985,6 +982,7 @@ func (s *serviceClient) setDisconnectedStatus() { s.mUp.Enable() s.mNetworks.Disable() s.mExitNode.Disable() + s.cancelExitNodeRetry() go s.updateExitNodes() } @@ -1090,7 +1088,6 @@ func (s *serviceClient) onTrayReady() { // update exit node menu in case service is already connected go s.updateExitNodes() - s.update.SetOnUpdateListener(s.onUpdateAvailable) go func() { s.getSrvConfig() time.Sleep(100 * time.Millisecond) // To prevent race condition caused by systray not being fully initialized and ignoring setIcon @@ -1134,6 +1131,13 @@ func (s *serviceClient) onTrayReady() { } } }) + s.eventManager.AddHandler(func(event *proto.SystemEvent) { + if newVersion, ok := event.Metadata["new_version_available"]; ok { + _, enforced := event.Metadata["enforced"] + log.Infof("received new_version_available event: version=%s enforced=%v", newVersion, enforced) + s.onUpdateAvailable(newVersion, enforced) + } + }) go s.eventManager.Start(s.ctx) go s.eventHandler.listen(s.ctx) @@ -1506,10 +1510,18 @@ func protoConfigToConfig(cfg *proto.GetConfigResponse) *profilemanager.Config { return &config } -func (s *serviceClient) onUpdateAvailable() { +func (s *serviceClient) onUpdateAvailable(newVersion string, enforced bool) { s.updateIndicationLock.Lock() defer s.updateIndicationLock.Unlock() + s.isEnforcedUpdate = enforced + if enforced { + s.mUpdate.SetTitle("Install version " + newVersion) + } else { + s.lastNotifiedVersion = "" + s.mUpdate.SetTitle("Download latest version") + } + s.mUpdate.Show() s.isUpdateIconActive = true @@ -1518,6 +1530,11 @@ func (s *serviceClient) onUpdateAvailable() { } else { systray.SetTemplateIcon(iconUpdateDisconnectedMacOS, s.icUpdateDisconnected) } + + if enforced && s.lastNotifiedVersion != newVersion { + s.lastNotifiedVersion = newVersion + s.app.SendNotification(fyne.NewNotification("Update available", "A new version "+newVersion+" is ready to install")) + } } // onSessionExpire sends a notification to the user when the session expires. diff --git a/client/ui/event/event.go b/client/ui/event/event.go index 4d949416d..b8ed09a5c 100644 --- a/client/ui/event/event.go +++ b/client/ui/event/event.go @@ -107,12 +107,7 @@ func (e *Manager) handleEvent(event *proto.SystemEvent) { handlers := slices.Clone(e.handlers) e.mu.Unlock() - // critical events are always shown - if !enabled && event.Severity != proto.SystemEvent_CRITICAL { - return - } - - if event.UserMessage != "" { + if event.UserMessage != "" && (enabled || event.Severity == proto.SystemEvent_CRITICAL) { title := e.getEventTitle(event) body := event.UserMessage id := event.Metadata["id"] diff --git a/client/ui/event_handler.go b/client/ui/event_handler.go index 2216c8aeb..60a580dae 100644 --- a/client/ui/event_handler.go +++ b/client/ui/event_handler.go @@ -82,7 +82,7 @@ func (h *eventHandler) handleConnectClick() { go func() { defer connectCancel() - if err := h.client.menuUpClick(connectCtx, true); err != nil { + if err := h.client.menuUpClick(connectCtx); err != nil { st, ok := status.FromError(err) if errors.Is(err, context.Canceled) || (ok && st.Code() == codes.Canceled) { log.Debugf("connect operation cancelled by user") @@ -100,8 +100,7 @@ func (h *eventHandler) handleConnectClick() { func (h *eventHandler) handleDisconnectClick() { h.client.mDown.Disable() - - h.client.exitNodeStates = []exitNodeState{} + h.client.cancelExitNodeRetry() if h.client.connectCancel != nil { log.Debugf("cancelling ongoing connect operation") @@ -212,9 +211,42 @@ func (h *eventHandler) handleGitHubClick() { } func (h *eventHandler) handleUpdateClick() { - if err := openURL(version.DownloadUrl()); err != nil { - log.Errorf("failed to open download URL: %v", err) + h.client.updateIndicationLock.Lock() + enforced := h.client.isEnforcedUpdate + h.client.updateIndicationLock.Unlock() + + if !enforced { + if err := openURL(version.DownloadUrl()); err != nil { + log.Errorf("failed to open download URL: %v", err) + } + return } + + // prevent blocking against a busy server + h.client.mUpdate.Disable() + go func() { + defer h.client.mUpdate.Enable() + conn, err := h.client.getSrvClient(defaultFailTimeout) + if err != nil { + log.Errorf("failed to get service client for update: %v", err) + _ = openURL(version.DownloadUrl()) + return + } + + resp, err := conn.TriggerUpdate(h.client.ctx, &proto.TriggerUpdateRequest{}) + if err != nil { + log.Errorf("TriggerUpdate failed: %v", err) + _ = openURL(version.DownloadUrl()) + return + } + if !resp.Success { + log.Errorf("TriggerUpdate failed: %s", resp.ErrorMsg) + _ = openURL(version.DownloadUrl()) + return + } + + log.Infof("update triggered via daemon") + }() } func (h *eventHandler) handleNetworksClick() { diff --git a/client/ui/network.go b/client/ui/network.go index 9a5ad7662..ed03f5ada 100644 --- a/client/ui/network.go +++ b/client/ui/network.go @@ -6,7 +6,6 @@ import ( "context" "fmt" "runtime" - "slices" "sort" "strings" "time" @@ -34,11 +33,6 @@ const ( type filter string -type exitNodeState struct { - id string - selected bool -} - func (s *serviceClient) showNetworksUI() { s.wNetworks = s.app.NewWindow("Networks") s.wNetworks.SetOnClosed(s.cancel) @@ -335,16 +329,75 @@ func (s *serviceClient) updateNetworksBasedOnDisplayTab(tabs *container.AppTabs, s.updateNetworks(grid, f) } -func (s *serviceClient) updateExitNodes() { +// startExitNodeRefresh initiates exit node menu refresh after connecting. +// On Windows, TrayOpenedCh is not supported by the systray library, so we use +// a background poller to keep exit nodes in sync while connected. +// On macOS/Linux, TrayOpenedCh handles refreshes on each tray open. +func (s *serviceClient) startExitNodeRefresh() { + s.cancelExitNodeRetry() + + if runtime.GOOS == "windows" { + ctx, cancel := context.WithCancel(s.ctx) + s.exitNodeMu.Lock() + s.exitNodeRetryCancel = cancel + s.exitNodeMu.Unlock() + + go s.pollExitNodes(ctx) + } else { + go s.updateExitNodes() + } +} + +func (s *serviceClient) cancelExitNodeRetry() { + s.exitNodeMu.Lock() + if s.exitNodeRetryCancel != nil { + s.exitNodeRetryCancel() + s.exitNodeRetryCancel = nil + } + s.exitNodeMu.Unlock() +} + +// pollExitNodes periodically refreshes exit nodes while connected. +// Uses a short initial interval to catch routes from the management sync, +// then switches to a longer interval for ongoing updates. +func (s *serviceClient) pollExitNodes(ctx context.Context) { + // Initial fast polling to catch routes as they appear after connect. + for i := 0; i < 5; i++ { + if s.updateExitNodes() { + break + } + select { + case <-ctx.Done(): + return + case <-time.After(2 * time.Second): + } + } + + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.updateExitNodes() + } + } +} + +// updateExitNodes fetches exit nodes from the daemon and recreates the menu. +// Returns true if exit nodes were found. +func (s *serviceClient) updateExitNodes() bool { conn, err := s.getSrvClient(defaultFailTimeout) if err != nil { log.Errorf("get client: %v", err) - return + return false } exitNodes, err := s.getExitNodes(conn) if err != nil { log.Errorf("get exit nodes: %v", err) - return + return false } s.exitNodeMu.Lock() @@ -354,28 +407,14 @@ func (s *serviceClient) updateExitNodes() { if len(s.mExitNodeItems) > 0 { s.mExitNode.Enable() - } else { - s.mExitNode.Disable() + return true } + + s.mExitNode.Disable() + return false } func (s *serviceClient) recreateExitNodeMenu(exitNodes []*proto.Network) { - var exitNodeIDs []exitNodeState - for _, node := range exitNodes { - exitNodeIDs = append(exitNodeIDs, exitNodeState{ - id: node.ID, - selected: node.Selected, - }) - } - - sort.Slice(exitNodeIDs, func(i, j int) bool { - return exitNodeIDs[i].id < exitNodeIDs[j].id - }) - if slices.Equal(s.exitNodeStates, exitNodeIDs) { - log.Debug("Exit node menu already up to date") - return - } - for _, node := range s.mExitNodeItems { node.cancel() node.Hide() @@ -413,8 +452,6 @@ func (s *serviceClient) recreateExitNodeMenu(exitNodes []*proto.Network) { go s.handleChecked(ctx, node.ID, menuItem) } - s.exitNodeStates = exitNodeIDs - if showDeselectAll { s.mExitNode.AddSeparator() deselectAllItem := s.mExitNode.AddSubMenuItem("Deselect All", "Deselect All") diff --git a/client/ui/profile.go b/client/ui/profile.go index a38d8918a..74189c9a0 100644 --- a/client/ui/profile.go +++ b/client/ui/profile.go @@ -397,7 +397,7 @@ type profileMenu struct { logoutSubItem *subItem profilesState []Profile downClickCallback func() error - upClickCallback func(context.Context, bool) error + upClickCallback func(context.Context) error getSrvClientCallback func(timeout time.Duration) (proto.DaemonServiceClient, error) loadSettingsCallback func() app fyne.App @@ -411,7 +411,7 @@ type newProfileMenuArgs struct { profileMenuItem *systray.MenuItem emailMenuItem *systray.MenuItem downClickCallback func() error - upClickCallback func(context.Context, bool) error + upClickCallback func(context.Context) error getSrvClientCallback func(timeout time.Duration) (proto.DaemonServiceClient, error) loadSettingsCallback func() app fyne.App @@ -579,7 +579,7 @@ func (p *profileMenu) refresh() { connectCtx, connectCancel := context.WithCancel(p.ctx) p.serviceClient.connectCancel = connectCancel - if err := p.upClickCallback(connectCtx, false); err != nil { + if err := p.upClickCallback(connectCtx); err != nil { log.Errorf("failed to handle up click after switching profile: %v", err) } diff --git a/client/ui/quickactions.go b/client/ui/quickactions.go index 76440d684..bf47ac434 100644 --- a/client/ui/quickactions.go +++ b/client/ui/quickactions.go @@ -267,7 +267,7 @@ func (s *serviceClient) showQuickActionsUI() { connCmd := connectCommand{ connectClient: func() error { - return s.menuUpClick(s.ctx, false) + return s.menuUpClick(s.ctx) }, } diff --git a/combined/Dockerfile b/combined/Dockerfile new file mode 100644 index 000000000..357e10cf8 --- /dev/null +++ b/combined/Dockerfile @@ -0,0 +1,5 @@ +FROM ubuntu:24.04 +RUN apt update && apt install -y ca-certificates && rm -fr /var/cache/apt +ENTRYPOINT [ "/go/bin/netbird-server" ] +CMD ["--config", "/etc/netbird/config.yaml"] +COPY netbird-server /go/bin/netbird-server \ No newline at end of file diff --git a/combined/Dockerfile.multistage b/combined/Dockerfile.multistage new file mode 100644 index 000000000..ef3d68c6e --- /dev/null +++ b/combined/Dockerfile.multistage @@ -0,0 +1,25 @@ +FROM golang:1.25-bookworm AS builder +WORKDIR /app + +# Install build dependencies +RUN apt-get update && apt-get install -y gcc libc6-dev git && rm -rf /var/lib/apt/lists/* + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +# Build with version info from git (matching goreleaser ldflags) +RUN CGO_ENABLED=1 GOOS=linux go build \ + -ldflags="-s -w \ + -X github.com/netbirdio/netbird/version.version=$(git describe --tags --always --dirty 2>/dev/null || echo 'dev') \ + -X main.commit=$(git rev-parse --short HEAD 2>/dev/null || echo 'unknown') \ + -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ) \ + -X main.builtBy=docker" \ + -o netbird-server ./combined + +FROM ubuntu:24.04 +RUN apt update && apt install -y ca-certificates && rm -fr /var/cache/apt +ENTRYPOINT [ "/go/bin/netbird-server" ] +CMD ["--config", "/etc/netbird/config.yaml"] +COPY --from=builder /app/netbird-server /go/bin/netbird-server diff --git a/combined/LICENSE b/combined/LICENSE new file mode 100644 index 000000000..be3f7b28e --- /dev/null +++ b/combined/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/combined/cmd/config.go b/combined/cmd/config.go new file mode 100644 index 000000000..85664d0d2 --- /dev/null +++ b/combined/cmd/config.go @@ -0,0 +1,765 @@ +package cmd + +import ( + "context" + "fmt" + "net" + "net/netip" + "os" + "path" + "path/filepath" + "strings" + "time" + + log "github.com/sirupsen/logrus" + "gopkg.in/yaml.v3" + + "github.com/netbirdio/netbird/management/server/idp" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/util" + "github.com/netbirdio/netbird/util/crypt" + + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" +) + +// CombinedConfig is the root configuration for the combined server. +// The combined server is primarily a Management server with optional embedded +// Signal, Relay, and STUN services. +// +// Architecture: +// - Management: Always runs locally (this IS the management server) +// - Signal: Runs locally by default; disabled if server.signalUri is set +// - Relay: Runs locally by default; disabled if server.relays is set +// - STUN: Runs locally on port 3478 by default; disabled if server.stuns is set +// +// All user-facing settings are under "server". The relay/signal/management +// fields are internal and populated automatically from server settings. +type CombinedConfig struct { + Server ServerConfig `yaml:"server"` + + // Internal configs - populated from Server settings, not user-configurable + Relay RelayConfig `yaml:"-"` + Signal SignalConfig `yaml:"-"` + Management ManagementConfig `yaml:"-"` +} + +// ServerConfig contains server-wide settings +// In simplified mode, this contains all configuration +type ServerConfig struct { + ListenAddress string `yaml:"listenAddress"` + MetricsPort int `yaml:"metricsPort"` + HealthcheckAddress string `yaml:"healthcheckAddress"` + LogLevel string `yaml:"logLevel"` + LogFile string `yaml:"logFile"` + TLS TLSConfig `yaml:"tls"` + + // Simplified config fields (used when relay/signal/management sections are omitted) + ExposedAddress string `yaml:"exposedAddress"` // Public address with protocol (e.g., "https://example.com:443") + StunPorts []int `yaml:"stunPorts"` // STUN ports (empty to disable local STUN) + AuthSecret string `yaml:"authSecret"` // Shared secret for relay authentication + DataDir string `yaml:"dataDir"` // Data directory for all services + + // External service overrides (simplified mode) + // When these are set, the corresponding local service is NOT started + // and these values are used for client configuration instead + Stuns []HostConfig `yaml:"stuns"` // External STUN servers (disables local STUN) + Relays RelaysConfig `yaml:"relays"` // External relay servers (disables local relay) + SignalURI string `yaml:"signalUri"` // External signal server (disables local signal) + + // Management settings (simplified mode) + DisableAnonymousMetrics bool `yaml:"disableAnonymousMetrics"` + DisableGeoliteUpdate bool `yaml:"disableGeoliteUpdate"` + Auth AuthConfig `yaml:"auth"` + Store StoreConfig `yaml:"store"` + ActivityStore StoreConfig `yaml:"activityStore"` + AuthStore StoreConfig `yaml:"authStore"` + ReverseProxy ReverseProxyConfig `yaml:"reverseProxy"` +} + +// TLSConfig contains TLS/HTTPS settings +type TLSConfig struct { + CertFile string `yaml:"certFile"` + KeyFile string `yaml:"keyFile"` + LetsEncrypt LetsEncryptConfig `yaml:"letsencrypt"` +} + +// LetsEncryptConfig contains Let's Encrypt settings +type LetsEncryptConfig struct { + Enabled bool `yaml:"enabled"` + DataDir string `yaml:"dataDir"` + Domains []string `yaml:"domains"` + Email string `yaml:"email"` + AWSRoute53 bool `yaml:"awsRoute53"` +} + +// RelayConfig contains relay service settings +type RelayConfig struct { + Enabled bool `yaml:"enabled"` + ExposedAddress string `yaml:"exposedAddress"` + AuthSecret string `yaml:"authSecret"` + LogLevel string `yaml:"logLevel"` + Stun StunConfig `yaml:"stun"` +} + +// StunConfig contains embedded STUN service settings +type StunConfig struct { + Enabled bool `yaml:"enabled"` + Ports []int `yaml:"ports"` + LogLevel string `yaml:"logLevel"` +} + +// SignalConfig contains signal service settings +type SignalConfig struct { + Enabled bool `yaml:"enabled"` + LogLevel string `yaml:"logLevel"` +} + +// ManagementConfig contains management service settings +type ManagementConfig struct { + Enabled bool `yaml:"enabled"` + LogLevel string `yaml:"logLevel"` + DataDir string `yaml:"dataDir"` + DnsDomain string `yaml:"dnsDomain"` + DisableAnonymousMetrics bool `yaml:"disableAnonymousMetrics"` + DisableGeoliteUpdate bool `yaml:"disableGeoliteUpdate"` + DisableDefaultPolicy bool `yaml:"disableDefaultPolicy"` + Auth AuthConfig `yaml:"auth"` + Stuns []HostConfig `yaml:"stuns"` + Relays RelaysConfig `yaml:"relays"` + SignalURI string `yaml:"signalUri"` + Store StoreConfig `yaml:"store"` + ReverseProxy ReverseProxyConfig `yaml:"reverseProxy"` +} + +// AuthConfig contains authentication/identity provider settings +type AuthConfig struct { + Issuer string `yaml:"issuer"` + LocalAuthDisabled bool `yaml:"localAuthDisabled"` + SignKeyRefreshEnabled bool `yaml:"signKeyRefreshEnabled"` + Storage AuthStorageConfig `yaml:"storage"` + DashboardRedirectURIs []string `yaml:"dashboardRedirectURIs"` + CLIRedirectURIs []string `yaml:"cliRedirectURIs"` + Owner *AuthOwnerConfig `yaml:"owner,omitempty"` +} + +// AuthStorageConfig contains auth storage settings +type AuthStorageConfig struct { + Type string `yaml:"type"` + File string `yaml:"file"` +} + +// AuthOwnerConfig contains initial admin user settings +type AuthOwnerConfig struct { + Email string `yaml:"email"` + Password string `yaml:"password"` +} + +// HostConfig represents a STUN/TURN/Signal host +type HostConfig struct { + URI string `yaml:"uri"` + Proto string `yaml:"proto,omitempty"` // udp, dtls, tcp, http, https - defaults based on URI scheme + Username string `yaml:"username,omitempty"` + Password string `yaml:"password,omitempty"` +} + +// RelaysConfig contains external relay server settings for clients +type RelaysConfig struct { + Addresses []string `yaml:"addresses"` + CredentialsTTL string `yaml:"credentialsTTL"` + Secret string `yaml:"secret"` +} + +// StoreConfig contains database settings +type StoreConfig struct { + Engine string `yaml:"engine"` + EncryptionKey string `yaml:"encryptionKey"` + DSN string `yaml:"dsn"` // Connection string for postgres or mysql engines + File string `yaml:"file"` // SQLite database file path (optional, defaults to dataDir) +} + +// ReverseProxyConfig contains reverse proxy settings +type ReverseProxyConfig struct { + TrustedHTTPProxies []string `yaml:"trustedHTTPProxies"` + TrustedHTTPProxiesCount uint `yaml:"trustedHTTPProxiesCount"` + TrustedPeers []string `yaml:"trustedPeers"` +} + +// DefaultConfig returns a CombinedConfig with default values +func DefaultConfig() *CombinedConfig { + return &CombinedConfig{ + Server: ServerConfig{ + ListenAddress: ":443", + MetricsPort: 9090, + HealthcheckAddress: ":9000", + LogLevel: "info", + LogFile: "console", + StunPorts: []int{3478}, + DataDir: "/var/lib/netbird/", + Auth: AuthConfig{ + Storage: AuthStorageConfig{ + Type: "sqlite3", + }, + }, + Store: StoreConfig{ + Engine: "sqlite", + }, + }, + Relay: RelayConfig{ + // LogLevel inherited from Server.LogLevel via ApplySimplifiedDefaults + Stun: StunConfig{ + Enabled: false, + Ports: []int{3478}, + // LogLevel inherited from Server.LogLevel via ApplySimplifiedDefaults + }, + }, + Signal: SignalConfig{ + // LogLevel inherited from Server.LogLevel via ApplySimplifiedDefaults + }, + Management: ManagementConfig{ + DataDir: "/var/lib/netbird/", + Auth: AuthConfig{ + Storage: AuthStorageConfig{ + Type: "sqlite3", + }, + }, + Relays: RelaysConfig{ + CredentialsTTL: "12h", + }, + Store: StoreConfig{ + Engine: "sqlite", + }, + }, + } +} + +// hasRequiredSettings returns true if the configuration has the required server settings +func (c *CombinedConfig) hasRequiredSettings() bool { + return c.Server.ExposedAddress != "" +} + +// parseExposedAddress extracts protocol, host, and host:port from the exposed address +// Input format: "https://example.com:443" or "http://example.com:8080" or "example.com:443" +// Returns: protocol ("https" or "http"), hostname only, and host:port +func parseExposedAddress(exposedAddress string) (protocol, hostname, hostPort string) { + // Default to https if no protocol specified + protocol = "https" + hostPort = exposedAddress + + // Check for protocol prefix + if strings.HasPrefix(exposedAddress, "https://") { + protocol = "https" + hostPort = strings.TrimPrefix(exposedAddress, "https://") + } else if strings.HasPrefix(exposedAddress, "http://") { + protocol = "http" + hostPort = strings.TrimPrefix(exposedAddress, "http://") + } + + // Extract hostname (without port) + hostname = hostPort + if host, _, err := net.SplitHostPort(hostPort); err == nil { + hostname = host + } + + return protocol, hostname, hostPort +} + +// ApplySimplifiedDefaults populates internal relay/signal/management configs from server settings. +// Management is always enabled. Signal, Relay, and STUN are enabled unless external +// overrides are configured (server.signalUri, server.relays, server.stuns). +func (c *CombinedConfig) ApplySimplifiedDefaults() { + if !c.hasRequiredSettings() { + return + } + + // Parse exposed address to extract protocol and hostname + exposedProto, exposedHost, exposedHostPort := parseExposedAddress(c.Server.ExposedAddress) + + // Check for external service overrides + hasExternalRelay := len(c.Server.Relays.Addresses) > 0 + hasExternalSignal := c.Server.SignalURI != "" + hasExternalStuns := len(c.Server.Stuns) > 0 + + // Default stunPorts to [3478] if not specified and no external STUN + if len(c.Server.StunPorts) == 0 && !hasExternalStuns { + c.Server.StunPorts = []int{3478} + } + + c.applyRelayDefaults(exposedProto, exposedHostPort, hasExternalRelay, hasExternalStuns) + c.applySignalDefaults(hasExternalSignal) + c.applyManagementDefaults(exposedHost) + + // Auto-configure client settings (stuns, relays, signalUri) + c.autoConfigureClientSettings(exposedProto, exposedHost, exposedHostPort, hasExternalStuns, hasExternalRelay, hasExternalSignal) +} + +// applyRelayDefaults configures the relay service if no external relay is configured. +func (c *CombinedConfig) applyRelayDefaults(exposedProto, exposedHostPort string, hasExternalRelay, hasExternalStuns bool) { + if hasExternalRelay { + return + } + + c.Relay.Enabled = true + relayProto := "rel" + if exposedProto == "https" { + relayProto = "rels" + } + c.Relay.ExposedAddress = fmt.Sprintf("%s://%s", relayProto, exposedHostPort) + c.Relay.AuthSecret = c.Server.AuthSecret + if c.Relay.LogLevel == "" { + c.Relay.LogLevel = c.Server.LogLevel + } + + // Enable local STUN only if no external STUN servers and stunPorts are configured + if !hasExternalStuns && len(c.Server.StunPorts) > 0 { + c.Relay.Stun.Enabled = true + c.Relay.Stun.Ports = c.Server.StunPorts + if c.Relay.Stun.LogLevel == "" { + c.Relay.Stun.LogLevel = c.Server.LogLevel + } + } +} + +// applySignalDefaults configures the signal service if no external signal is configured. +func (c *CombinedConfig) applySignalDefaults(hasExternalSignal bool) { + if hasExternalSignal { + return + } + + c.Signal.Enabled = true + if c.Signal.LogLevel == "" { + c.Signal.LogLevel = c.Server.LogLevel + } +} + +// applyManagementDefaults configures the management service (always enabled). +func (c *CombinedConfig) applyManagementDefaults(exposedHost string) { + c.Management.Enabled = true + if c.Management.LogLevel == "" { + c.Management.LogLevel = c.Server.LogLevel + } + if c.Management.DataDir == "" || c.Management.DataDir == "/var/lib/netbird/" { + c.Management.DataDir = c.Server.DataDir + } + c.Management.DnsDomain = exposedHost + c.Management.DisableAnonymousMetrics = c.Server.DisableAnonymousMetrics + c.Management.DisableGeoliteUpdate = c.Server.DisableGeoliteUpdate + // Copy auth config from server if management auth issuer is not set + if c.Management.Auth.Issuer == "" && c.Server.Auth.Issuer != "" { + c.Management.Auth = c.Server.Auth + } + + // Copy store config from server if not set + if c.Management.Store.Engine == "" || c.Management.Store.Engine == "sqlite" { + if c.Server.Store.Engine != "" { + c.Management.Store = c.Server.Store + } + } + + // Copy reverse proxy config from server + if len(c.Server.ReverseProxy.TrustedHTTPProxies) > 0 || c.Server.ReverseProxy.TrustedHTTPProxiesCount > 0 || len(c.Server.ReverseProxy.TrustedPeers) > 0 { + c.Management.ReverseProxy = c.Server.ReverseProxy + } +} + +// autoConfigureClientSettings sets up STUN/relay/signal URIs for clients +// External overrides from server config take precedence over auto-generated values +func (c *CombinedConfig) autoConfigureClientSettings(exposedProto, exposedHost, exposedHostPort string, hasExternalStuns, hasExternalRelay, hasExternalSignal bool) { + // Determine relay protocol from exposed protocol + relayProto := "rel" + if exposedProto == "https" { + relayProto = "rels" + } + + // Configure STUN servers for clients + if hasExternalStuns { + // Use external STUN servers from server config + c.Management.Stuns = c.Server.Stuns + } else if len(c.Server.StunPorts) > 0 && len(c.Management.Stuns) == 0 { + // Auto-configure local STUN servers for all ports + for _, port := range c.Server.StunPorts { + c.Management.Stuns = append(c.Management.Stuns, HostConfig{ + URI: fmt.Sprintf("stun:%s:%d", exposedHost, port), + }) + } + } + + // Configure relay for clients + if hasExternalRelay { + // Use external relay config from server + c.Management.Relays = c.Server.Relays + } else if len(c.Management.Relays.Addresses) == 0 { + // Auto-configure local relay + c.Management.Relays.Addresses = []string{ + fmt.Sprintf("%s://%s", relayProto, exposedHostPort), + } + } + if c.Management.Relays.Secret == "" { + c.Management.Relays.Secret = c.Server.AuthSecret + } + if c.Management.Relays.CredentialsTTL == "" { + c.Management.Relays.CredentialsTTL = "12h" + } + + // Configure signal for clients + if hasExternalSignal { + // Use external signal URI from server config + c.Management.SignalURI = c.Server.SignalURI + } else if c.Management.SignalURI == "" { + // Auto-configure local signal + c.Management.SignalURI = fmt.Sprintf("%s://%s", exposedProto, exposedHostPort) + } +} + +// LoadConfig loads configuration from a YAML file +func LoadConfig(configPath string) (*CombinedConfig, error) { + cfg := DefaultConfig() + + if configPath == "" { + return cfg, nil + } + + data, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("failed to parse config file: %w", err) + } + + // Populate internal configs from server settings + cfg.ApplySimplifiedDefaults() + + return cfg, nil +} + +// Validate validates the configuration +func (c *CombinedConfig) Validate() error { + if c.Server.ExposedAddress == "" { + return fmt.Errorf("server.exposedAddress is required") + } + if c.Server.DataDir == "" { + return fmt.Errorf("server.dataDir is required") + } + + // Validate STUN ports + seen := make(map[int]bool) + for _, port := range c.Server.StunPorts { + if port <= 0 || port > 65535 { + return fmt.Errorf("invalid server.stunPorts value %d: must be between 1 and 65535", port) + } + if seen[port] { + return fmt.Errorf("duplicate STUN port %d in server.stunPorts", port) + } + seen[port] = true + } + + // authSecret is required only if running local relay (no external relay configured) + hasExternalRelay := len(c.Server.Relays.Addresses) > 0 + if !hasExternalRelay && c.Server.AuthSecret == "" { + return fmt.Errorf("server.authSecret is required when running local relay") + } + + return nil +} + +// HasTLSCert returns true if TLS certificate files are configured +func (c *CombinedConfig) HasTLSCert() bool { + return c.Server.TLS.CertFile != "" && c.Server.TLS.KeyFile != "" +} + +// HasLetsEncrypt returns true if Let's Encrypt is configured +func (c *CombinedConfig) HasLetsEncrypt() bool { + return c.Server.TLS.LetsEncrypt.Enabled && + c.Server.TLS.LetsEncrypt.DataDir != "" && + len(c.Server.TLS.LetsEncrypt.Domains) > 0 +} + +// parseExplicitProtocol parses an explicit protocol string to nbconfig.Protocol +func parseExplicitProtocol(proto string) (nbconfig.Protocol, bool) { + switch strings.ToLower(proto) { + case "udp": + return nbconfig.UDP, true + case "dtls": + return nbconfig.DTLS, true + case "tcp": + return nbconfig.TCP, true + case "http": + return nbconfig.HTTP, true + case "https": + return nbconfig.HTTPS, true + default: + return "", false + } +} + +// parseStunProtocol determines protocol for STUN/TURN servers. +// stun: → UDP, stuns: → DTLS, turn: → UDP, turns: → DTLS +// Explicit proto overrides URI scheme. Defaults to UDP. +func parseStunProtocol(uri, proto string) nbconfig.Protocol { + if proto != "" { + if p, ok := parseExplicitProtocol(proto); ok { + return p + } + } + + uri = strings.ToLower(uri) + switch { + case strings.HasPrefix(uri, "stuns:"): + return nbconfig.DTLS + case strings.HasPrefix(uri, "turns:"): + return nbconfig.DTLS + default: + // stun:, turn:, or no scheme - default to UDP + return nbconfig.UDP + } +} + +// parseSignalProtocol determines protocol for Signal servers. +// https:// → HTTPS, http:// → HTTP. Defaults to HTTPS. +func parseSignalProtocol(uri string) nbconfig.Protocol { + uri = strings.ToLower(uri) + switch { + case strings.HasPrefix(uri, "http://"): + return nbconfig.HTTP + default: + // https:// or no scheme - default to HTTPS + return nbconfig.HTTPS + } +} + +// stripSignalProtocol removes the protocol prefix from a signal URI. +// Returns just the host:port (e.g., "selfhosted2.demo.netbird.io:443"). +func stripSignalProtocol(uri string) string { + uri = strings.TrimPrefix(uri, "https://") + uri = strings.TrimPrefix(uri, "http://") + return uri +} + +func buildRelayConfig(relays RelaysConfig) (*nbconfig.Relay, error) { + var ttl time.Duration + if relays.CredentialsTTL != "" { + var err error + ttl, err = time.ParseDuration(relays.CredentialsTTL) + if err != nil { + return nil, fmt.Errorf("invalid relay credentials TTL %q: %w", relays.CredentialsTTL, err) + } + } + return &nbconfig.Relay{ + Addresses: relays.Addresses, + CredentialsTTL: util.Duration{Duration: ttl}, + Secret: relays.Secret, + }, nil +} + +// buildEmbeddedIdPConfig builds the embedded IdP configuration. +// authStore overrides auth.storage when set. +func (c *CombinedConfig) buildEmbeddedIdPConfig(mgmt ManagementConfig) (*idp.EmbeddedIdPConfig, error) { + authStorageType := mgmt.Auth.Storage.Type + authStorageDSN := c.Server.AuthStore.DSN + if c.Server.AuthStore.Engine != "" { + authStorageType = c.Server.AuthStore.Engine + } + if authStorageType == "" { + authStorageType = "sqlite3" + } + authStorageFile := "" + if authStorageType == "postgres" { + if authStorageDSN == "" { + return nil, fmt.Errorf("authStore.dsn is required when authStore.engine is postgres") + } + } else { + authStorageFile = path.Join(mgmt.DataDir, "idp.db") + if c.Server.AuthStore.File != "" { + authStorageFile = c.Server.AuthStore.File + if !filepath.IsAbs(authStorageFile) { + authStorageFile = filepath.Join(mgmt.DataDir, authStorageFile) + } + } + } + + cfg := &idp.EmbeddedIdPConfig{ + Enabled: true, + Issuer: mgmt.Auth.Issuer, + LocalAuthDisabled: mgmt.Auth.LocalAuthDisabled, + SignKeyRefreshEnabled: mgmt.Auth.SignKeyRefreshEnabled, + Storage: idp.EmbeddedStorageConfig{ + Type: authStorageType, + Config: idp.EmbeddedStorageTypeConfig{ + File: authStorageFile, + DSN: authStorageDSN, + }, + }, + DashboardRedirectURIs: mgmt.Auth.DashboardRedirectURIs, + CLIRedirectURIs: mgmt.Auth.CLIRedirectURIs, + } + + if mgmt.Auth.Owner != nil && mgmt.Auth.Owner.Email != "" { + cfg.Owner = &idp.OwnerConfig{ + Email: mgmt.Auth.Owner.Email, + Hash: mgmt.Auth.Owner.Password, + } + } + + return cfg, nil +} + +// ToManagementConfig converts CombinedConfig to management server config +func (c *CombinedConfig) ToManagementConfig() (*nbconfig.Config, error) { + mgmt := c.Management + + // Build STUN hosts + var stuns []*nbconfig.Host + for _, s := range mgmt.Stuns { + stuns = append(stuns, &nbconfig.Host{ + URI: s.URI, + Proto: parseStunProtocol(s.URI, s.Proto), + Username: s.Username, + Password: s.Password, + }) + } + + // Build relay config + var relayConfig *nbconfig.Relay + if len(mgmt.Relays.Addresses) > 0 || mgmt.Relays.Secret != "" { + relay, err := buildRelayConfig(mgmt.Relays) + if err != nil { + return nil, err + } + relayConfig = relay + } + + // Build signal config + var signalConfig *nbconfig.Host + if mgmt.SignalURI != "" { + signalConfig = &nbconfig.Host{ + URI: stripSignalProtocol(mgmt.SignalURI), + Proto: parseSignalProtocol(mgmt.SignalURI), + } + } + + // Build store config + storeConfig := nbconfig.StoreConfig{ + Engine: types.Engine(mgmt.Store.Engine), + } + + // Build reverse proxy config + reverseProxy := nbconfig.ReverseProxy{ + TrustedHTTPProxiesCount: mgmt.ReverseProxy.TrustedHTTPProxiesCount, + } + for _, p := range mgmt.ReverseProxy.TrustedHTTPProxies { + if prefix, err := netip.ParsePrefix(p); err == nil { + reverseProxy.TrustedHTTPProxies = append(reverseProxy.TrustedHTTPProxies, prefix) + } + } + for _, p := range mgmt.ReverseProxy.TrustedPeers { + if prefix, err := netip.ParsePrefix(p); err == nil { + reverseProxy.TrustedPeers = append(reverseProxy.TrustedPeers, prefix) + } + } + + // Build HTTP config (required, even if empty) + httpConfig := &nbconfig.HttpServerConfig{} + + // Build embedded IDP config (always enabled in combined server) + embeddedIdP, err := c.buildEmbeddedIdPConfig(mgmt) + if err != nil { + return nil, err + } + + // Set HTTP config fields for embedded IDP + httpConfig.AuthIssuer = mgmt.Auth.Issuer + httpConfig.AuthAudience = "netbird-dashboard" + httpConfig.AuthClientID = httpConfig.AuthAudience + httpConfig.CLIAuthAudience = "netbird-cli" + httpConfig.AuthUserIDClaim = "sub" + httpConfig.AuthKeysLocation = mgmt.Auth.Issuer + "/keys" + httpConfig.OIDCConfigEndpoint = mgmt.Auth.Issuer + "/.well-known/openid-configuration" + httpConfig.IdpSignKeyRefreshEnabled = mgmt.Auth.SignKeyRefreshEnabled + callbackURL := strings.TrimSuffix(httpConfig.AuthIssuer, "/oauth2") + httpConfig.AuthCallbackURL = callbackURL + types.ProxyCallbackEndpointFull + + return &nbconfig.Config{ + Stuns: stuns, + Relay: relayConfig, + Signal: signalConfig, + Datadir: mgmt.DataDir, + DataStoreEncryptionKey: mgmt.Store.EncryptionKey, + HttpConfig: httpConfig, + StoreConfig: storeConfig, + ReverseProxy: reverseProxy, + DisableDefaultPolicy: mgmt.DisableDefaultPolicy, + EmbeddedIdP: embeddedIdP, + }, nil +} + +// ApplyEmbeddedIdPConfig applies embedded IdP configuration to the management config. +// This mirrors the logic in management/cmd/management.go ApplyEmbeddedIdPConfig. +func ApplyEmbeddedIdPConfig(ctx context.Context, cfg *nbconfig.Config, mgmtPort int, disableSingleAccMode bool) error { + if cfg.EmbeddedIdP == nil || !cfg.EmbeddedIdP.Enabled { + return nil + } + + // Embedded IdP requires single account mode + if disableSingleAccMode { + return fmt.Errorf("embedded IdP requires single account mode; multiple account mode is not supported with embedded IdP") + } + + // Set LocalAddress for embedded IdP, used for internal JWT validation + cfg.EmbeddedIdP.LocalAddress = fmt.Sprintf("localhost:%d", mgmtPort) + + // Set storage defaults based on Datadir + if cfg.EmbeddedIdP.Storage.Type == "" { + cfg.EmbeddedIdP.Storage.Type = "sqlite3" + } + if cfg.EmbeddedIdP.Storage.Config.File == "" && cfg.Datadir != "" { + cfg.EmbeddedIdP.Storage.Config.File = path.Join(cfg.Datadir, "idp.db") + } + + issuer := cfg.EmbeddedIdP.Issuer + + // Ensure HttpConfig exists + if cfg.HttpConfig == nil { + cfg.HttpConfig = &nbconfig.HttpServerConfig{} + } + + // Set HttpConfig values from EmbeddedIdP + cfg.HttpConfig.AuthIssuer = issuer + cfg.HttpConfig.AuthAudience = "netbird-dashboard" + cfg.HttpConfig.CLIAuthAudience = "netbird-cli" + cfg.HttpConfig.AuthUserIDClaim = "sub" + cfg.HttpConfig.AuthKeysLocation = issuer + "/keys" + cfg.HttpConfig.OIDCConfigEndpoint = issuer + "/.well-known/openid-configuration" + cfg.HttpConfig.IdpSignKeyRefreshEnabled = true + + return nil +} + +// EnsureEncryptionKey generates an encryption key if not set. +// Unlike management server, we don't write back to the config file. +func EnsureEncryptionKey(ctx context.Context, cfg *nbconfig.Config) error { + if cfg.DataStoreEncryptionKey != "" { + return nil + } + + log.WithContext(ctx).Infof("DataStoreEncryptionKey is not set, generating a new key") + key, err := crypt.GenerateKey() + if err != nil { + return fmt.Errorf("failed to generate datastore encryption key: %v", err) + } + cfg.DataStoreEncryptionKey = key + keyPreview := key[:8] + "..." + log.WithContext(ctx).Warnf("DataStoreEncryptionKey generated (%s); add it to your config file under 'server.store.encryptionKey' to persist across restarts", keyPreview) + + return nil +} + +// LogConfigInfo logs informational messages about the loaded configuration +func LogConfigInfo(cfg *nbconfig.Config) { + if cfg.EmbeddedIdP != nil && cfg.EmbeddedIdP.Enabled { + log.Infof("running with the embedded IdP: %v", cfg.EmbeddedIdP.Issuer) + } + if cfg.Relay != nil { + log.Infof("Relay addresses: %v", cfg.Relay.Addresses) + } +} diff --git a/combined/cmd/pprof.go b/combined/cmd/pprof.go new file mode 100644 index 000000000..37efd35f0 --- /dev/null +++ b/combined/cmd/pprof.go @@ -0,0 +1,33 @@ +//go:build pprof +// +build pprof + +package cmd + +import ( + "net/http" + _ "net/http/pprof" + "os" + + log "github.com/sirupsen/logrus" +) + +func init() { + addr := pprofAddr() + go pprof(addr) +} + +func pprofAddr() string { + listenAddr := os.Getenv("NB_PPROF_ADDR") + if listenAddr == "" { + return "localhost:6969" + } + + return listenAddr +} + +func pprof(listenAddr string) { + log.Infof("listening pprof on: %s\n", listenAddr) + if err := http.ListenAndServe(listenAddr, nil); err != nil { + log.Fatalf("Failed to start pprof: %v", err) + } +} diff --git a/combined/cmd/root.go b/combined/cmd/root.go new file mode 100644 index 000000000..ea1ff908a --- /dev/null +++ b/combined/cmd/root.go @@ -0,0 +1,733 @@ +package cmd + +import ( + "context" + "crypto/sha256" + "crypto/tls" + "errors" + "fmt" + "net" + "net/http" + "os" + "os/signal" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/coder/websocket" + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "go.opentelemetry.io/otel/metric" + "google.golang.org/grpc" + + "github.com/netbirdio/netbird/encryption" + mgmtServer "github.com/netbirdio/netbird/management/internals/server" + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + "github.com/netbirdio/netbird/management/server/telemetry" + "github.com/netbirdio/netbird/relay/healthcheck" + relayServer "github.com/netbirdio/netbird/relay/server" + "github.com/netbirdio/netbird/relay/server/listener/ws" + sharedMetrics "github.com/netbirdio/netbird/shared/metrics" + "github.com/netbirdio/netbird/shared/relay/auth" + "github.com/netbirdio/netbird/shared/signal/proto" + signalServer "github.com/netbirdio/netbird/signal/server" + "github.com/netbirdio/netbird/stun" + "github.com/netbirdio/netbird/util" + "github.com/netbirdio/netbird/util/wsproxy" + wsproxyserver "github.com/netbirdio/netbird/util/wsproxy/server" +) + +var ( + configPath string + config *CombinedConfig + + rootCmd = &cobra.Command{ + Use: "combined", + Short: "Combined Netbird server (Management + Signal + Relay + STUN)", + Long: `Combined Netbird server for self-hosted deployments. + +All services (Management, Signal, Relay) are multiplexed on a single port. +Optional STUN server runs on separate UDP ports. + +Configuration is loaded from a YAML file specified with --config.`, + SilenceUsage: true, + SilenceErrors: true, + RunE: execute, + } +) + +func init() { + rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "path to YAML configuration file (required)") + _ = rootCmd.MarkPersistentFlagRequired("config") + + rootCmd.AddCommand(newTokenCommands()) +} + +func Execute() error { + return rootCmd.Execute() +} + +func waitForExitSignal() { + osSigs := make(chan os.Signal, 1) + signal.Notify(osSigs, syscall.SIGINT, syscall.SIGTERM) + <-osSigs +} + +func execute(cmd *cobra.Command, _ []string) error { + if err := initializeConfig(); err != nil { + return err + } + + // Management is required as the base server when signal or relay are enabled + if (config.Signal.Enabled || config.Relay.Enabled) && !config.Management.Enabled { + return fmt.Errorf("management must be enabled when signal or relay are enabled (provides the base HTTP server)") + } + + servers, err := createAllServers(cmd.Context(), config) + if err != nil { + return err + } + + // Register services with management's gRPC server using AfterInit hook + setupServerHooks(servers, config) + + // Start management server (this also starts the HTTP listener) + if servers.mgmtSrv != nil { + if err := servers.mgmtSrv.Start(cmd.Context()); err != nil { + cleanupSTUNListeners(servers.stunListeners) + return fmt.Errorf("failed to start management server: %w", err) + } + } + + // Start all other servers + wg := sync.WaitGroup{} + startServers(&wg, servers.relaySrv, servers.healthcheck, servers.stunServer, servers.metricsServer) + + waitForExitSignal() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err = shutdownServers(ctx, servers.relaySrv, servers.healthcheck, servers.stunServer, servers.mgmtSrv, servers.metricsServer) + wg.Wait() + return err +} + +// initializeConfig loads and validates the configuration, then initializes logging. +func initializeConfig() error { + var err error + config, err = LoadConfig(configPath) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + if err := config.Validate(); err != nil { + return fmt.Errorf("invalid config: %w", err) + } + + if err := util.InitLog(config.Server.LogLevel, config.Server.LogFile); err != nil { + return fmt.Errorf("failed to initialize log: %w", err) + } + + if dsn := config.Server.Store.DSN; dsn != "" { + switch strings.ToLower(config.Server.Store.Engine) { + case "postgres": + os.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", dsn) + case "mysql": + os.Setenv("NB_STORE_ENGINE_MYSQL_DSN", dsn) + } + } + if file := config.Server.Store.File; file != "" { + os.Setenv("NB_STORE_ENGINE_SQLITE_FILE", file) + } + + if engine := config.Server.ActivityStore.Engine; engine != "" { + engineLower := strings.ToLower(engine) + if engineLower == "postgres" && config.Server.ActivityStore.DSN == "" { + return fmt.Errorf("activityStore.dsn is required when activityStore.engine is postgres") + } + os.Setenv("NB_ACTIVITY_EVENT_STORE_ENGINE", engineLower) + if dsn := config.Server.ActivityStore.DSN; dsn != "" { + os.Setenv("NB_ACTIVITY_EVENT_POSTGRES_DSN", dsn) + } + } + if file := config.Server.ActivityStore.File; file != "" { + os.Setenv("NB_ACTIVITY_EVENT_SQLITE_FILE", file) + } + + log.Infof("Starting combined NetBird server") + logConfig(config) + logEnvVars() + return nil +} + +// serverInstances holds all server instances created during startup. +type serverInstances struct { + relaySrv *relayServer.Server + mgmtSrv *mgmtServer.BaseServer + signalSrv *signalServer.Server + healthcheck *healthcheck.Server + stunServer *stun.Server + stunListeners []*net.UDPConn + metricsServer *sharedMetrics.Metrics +} + +// createAllServers creates all server instances based on configuration. +func createAllServers(ctx context.Context, cfg *CombinedConfig) (*serverInstances, error) { + metricsServer, err := sharedMetrics.NewServer(cfg.Server.MetricsPort, "") + if err != nil { + return nil, fmt.Errorf("failed to create metrics server: %w", err) + } + servers := &serverInstances{ + metricsServer: metricsServer, + } + + _, tlsSupport, err := handleTLSConfig(cfg) + if err != nil { + return nil, fmt.Errorf("failed to setup TLS config: %w", err) + } + + if err := servers.createRelayServer(cfg, tlsSupport); err != nil { + return nil, err + } + + if err := servers.createManagementServer(ctx, cfg); err != nil { + return nil, err + } + + if err := servers.createSignalServer(ctx, cfg); err != nil { + return nil, err + } + + if err := servers.createHealthcheckServer(cfg); err != nil { + return nil, err + } + + return servers, nil +} + +func (s *serverInstances) createRelayServer(cfg *CombinedConfig, tlsSupport bool) error { + if !cfg.Relay.Enabled { + return nil + } + + var err error + s.stunListeners, err = createSTUNListeners(cfg) + if err != nil { + return err + } + + hashedSecret := sha256.Sum256([]byte(cfg.Relay.AuthSecret)) + authenticator := auth.NewTimedHMACValidator(hashedSecret[:], 24*time.Hour) + + relayCfg := relayServer.Config{ + Meter: s.metricsServer.Meter, + ExposedAddress: cfg.Relay.ExposedAddress, + AuthValidator: authenticator, + TLSSupport: tlsSupport, + } + + s.relaySrv, err = createRelayServer(relayCfg, s.stunListeners) + if err != nil { + return err + } + + log.Infof("Relay server created") + + if len(s.stunListeners) > 0 { + s.stunServer = stun.NewServer(s.stunListeners, cfg.Relay.Stun.LogLevel) + } + + return nil +} + +func (s *serverInstances) createManagementServer(ctx context.Context, cfg *CombinedConfig) error { + if !cfg.Management.Enabled { + return nil + } + + mgmtConfig, err := cfg.ToManagementConfig() + if err != nil { + return fmt.Errorf("failed to create management config: %w", err) + } + + _, portStr, portErr := net.SplitHostPort(cfg.Server.ListenAddress) + if portErr != nil { + portStr = "443" + } + mgmtPort, _ := strconv.Atoi(portStr) + + if err := ApplyEmbeddedIdPConfig(ctx, mgmtConfig, mgmtPort, false); err != nil { + cleanupSTUNListeners(s.stunListeners) + return fmt.Errorf("failed to apply embedded IdP config: %w", err) + } + + if err := EnsureEncryptionKey(ctx, mgmtConfig); err != nil { + cleanupSTUNListeners(s.stunListeners) + return fmt.Errorf("failed to ensure encryption key: %w", err) + } + + LogConfigInfo(mgmtConfig) + + s.mgmtSrv, err = createManagementServer(cfg, mgmtConfig) + if err != nil { + cleanupSTUNListeners(s.stunListeners) + return fmt.Errorf("failed to create management server: %w", err) + } + + // Inject externally-managed AppMetrics so management uses the shared metrics server + appMetrics, err := telemetry.NewAppMetricsWithMeter(ctx, s.metricsServer.Meter) + if err != nil { + cleanupSTUNListeners(s.stunListeners) + return fmt.Errorf("failed to create management app metrics: %w", err) + } + mgmtServer.Inject[telemetry.AppMetrics](s.mgmtSrv, appMetrics) + + log.Infof("Management server created") + return nil +} + +func (s *serverInstances) createSignalServer(ctx context.Context, cfg *CombinedConfig) error { + if !cfg.Signal.Enabled { + return nil + } + + var err error + s.signalSrv, err = signalServer.NewServer(ctx, s.metricsServer.Meter, "signal_") + if err != nil { + cleanupSTUNListeners(s.stunListeners) + return fmt.Errorf("failed to create signal server: %w", err) + } + + log.Infof("Signal server created") + return nil +} + +func (s *serverInstances) createHealthcheckServer(cfg *CombinedConfig) error { + hCfg := healthcheck.Config{ + ListenAddress: cfg.Server.HealthcheckAddress, + ServiceChecker: s.relaySrv, + } + + var err error + s.healthcheck, err = createHealthCheck(hCfg, s.stunListeners) + return err +} + +// setupServerHooks registers services with management's gRPC server. +func setupServerHooks(servers *serverInstances, cfg *CombinedConfig) { + if servers.mgmtSrv == nil { + return + } + + servers.mgmtSrv.AfterInit(func(s *mgmtServer.BaseServer) { + grpcSrv := s.GRPCServer() + + if servers.signalSrv != nil { + proto.RegisterSignalExchangeServer(grpcSrv, servers.signalSrv) + log.Infof("Signal server registered on port %s", cfg.Server.ListenAddress) + } + + s.SetHandlerFunc(createCombinedHandler(grpcSrv, s.APIHandler(), servers.relaySrv, servers.metricsServer.Meter, cfg)) + if servers.relaySrv != nil { + log.Infof("Relay WebSocket handler added (path: /relay)") + } + }) +} + +func startServers(wg *sync.WaitGroup, srv *relayServer.Server, httpHealthcheck *healthcheck.Server, stunServer *stun.Server, metricsServer *sharedMetrics.Metrics) { + if srv != nil { + instanceURL := srv.InstanceURL() + log.Infof("Relay server instance URL: %s", instanceURL.String()) + log.Infof("Relay WebSocket multiplexed on management port (no separate relay listener)") + } + + wg.Add(1) + go func() { + defer wg.Done() + log.Infof("running metrics server: %s%s", metricsServer.Addr, metricsServer.Endpoint) + if err := metricsServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("failed to start metrics server: %v", err) + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + if err := httpHealthcheck.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("failed to start healthcheck server: %v", err) + } + }() + + if stunServer != nil { + wg.Add(1) + go func() { + defer wg.Done() + if err := stunServer.Listen(); err != nil { + if errors.Is(err, stun.ErrServerClosed) { + return + } + log.Errorf("STUN server error: %v", err) + } + }() + } +} + +func shutdownServers(ctx context.Context, srv *relayServer.Server, httpHealthcheck *healthcheck.Server, stunServer *stun.Server, mgmtSrv *mgmtServer.BaseServer, metricsServer *sharedMetrics.Metrics) error { + var errs error + + if err := httpHealthcheck.Shutdown(ctx); err != nil { + errs = multierror.Append(errs, fmt.Errorf("failed to close healthcheck server: %w", err)) + } + + if stunServer != nil { + if err := stunServer.Shutdown(); err != nil { + errs = multierror.Append(errs, fmt.Errorf("failed to close STUN server: %w", err)) + } + } + + if srv != nil { + if err := srv.Shutdown(ctx); err != nil { + errs = multierror.Append(errs, fmt.Errorf("failed to close relay server: %w", err)) + } + } + + if mgmtSrv != nil { + log.Infof("shutting down management and signal servers") + if err := mgmtSrv.Stop(); err != nil { + errs = multierror.Append(errs, fmt.Errorf("failed to close management server: %w", err)) + } + } + + if metricsServer != nil { + log.Infof("shutting down metrics server") + if err := metricsServer.Shutdown(ctx); err != nil { + errs = multierror.Append(errs, fmt.Errorf("failed to close metrics server: %w", err)) + } + } + + return errs +} + +func createHealthCheck(hCfg healthcheck.Config, stunListeners []*net.UDPConn) (*healthcheck.Server, error) { + httpHealthcheck, err := healthcheck.NewServer(hCfg) + if err != nil { + cleanupSTUNListeners(stunListeners) + return nil, fmt.Errorf("failed to create healthcheck server: %w", err) + } + return httpHealthcheck, nil +} + +func createRelayServer(cfg relayServer.Config, stunListeners []*net.UDPConn) (*relayServer.Server, error) { + srv, err := relayServer.NewServer(cfg) + if err != nil { + cleanupSTUNListeners(stunListeners) + return nil, fmt.Errorf("failed to create relay server: %w", err) + } + return srv, nil +} + +func cleanupSTUNListeners(stunListeners []*net.UDPConn) { + for _, l := range stunListeners { + _ = l.Close() + } +} + +func createSTUNListeners(cfg *CombinedConfig) ([]*net.UDPConn, error) { + var stunListeners []*net.UDPConn + if cfg.Relay.Stun.Enabled { + for _, port := range cfg.Relay.Stun.Ports { + listener, err := net.ListenUDP("udp", &net.UDPAddr{Port: port}) + if err != nil { + cleanupSTUNListeners(stunListeners) + return nil, fmt.Errorf("failed to create STUN listener on port %d: %w", port, err) + } + stunListeners = append(stunListeners, listener) + log.Infof("STUN server listening on UDP port %d", port) + } + } + return stunListeners, nil +} + +func handleTLSConfig(cfg *CombinedConfig) (*tls.Config, bool, error) { + tlsCfg := cfg.Server.TLS + + if tlsCfg.LetsEncrypt.AWSRoute53 { + log.Debugf("using Let's Encrypt DNS resolver with Route 53 support") + r53 := encryption.Route53TLS{ + DataDir: tlsCfg.LetsEncrypt.DataDir, + Email: tlsCfg.LetsEncrypt.Email, + Domains: tlsCfg.LetsEncrypt.Domains, + } + tc, err := r53.GetCertificate() + if err != nil { + return nil, false, err + } + return tc, true, nil + } + + if cfg.HasLetsEncrypt() { + log.Infof("setting up TLS with Let's Encrypt") + certManager, err := encryption.CreateCertManager(tlsCfg.LetsEncrypt.DataDir, tlsCfg.LetsEncrypt.Domains...) + if err != nil { + return nil, false, fmt.Errorf("failed creating LetsEncrypt cert manager: %w", err) + } + return certManager.TLSConfig(), true, nil + } + + if cfg.HasTLSCert() { + log.Debugf("using file based TLS config") + tc, err := encryption.LoadTLSConfig(tlsCfg.CertFile, tlsCfg.KeyFile) + if err != nil { + return nil, false, err + } + return tc, true, nil + } + + return nil, false, nil +} + +func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (*mgmtServer.BaseServer, error) { + mgmt := cfg.Management + + // Extract port from listen address + _, portStr, err := net.SplitHostPort(cfg.Server.ListenAddress) + if err != nil { + // If no port specified, assume default + portStr = "443" + } + mgmtPort, _ := strconv.Atoi(portStr) + + mgmtSrv := mgmtServer.NewServer( + &mgmtServer.Config{ + NbConfig: mgmtConfig, + DNSDomain: "", + MgmtSingleAccModeDomain: "", + AutoResolveDomains: true, + MgmtPort: mgmtPort, + MgmtMetricsPort: cfg.Server.MetricsPort, + DisableMetrics: mgmt.DisableAnonymousMetrics, + DisableGeoliteUpdate: mgmt.DisableGeoliteUpdate, + // Always enable user deletion from IDP in combined server (embedded IdP is always enabled) + UserDeleteFromIDPEnabled: true, + }, + ) + + return mgmtSrv, nil +} + +// createCombinedHandler creates an HTTP handler that multiplexes Management, Signal (via wsproxy), and Relay WebSocket traffic +func createCombinedHandler(grpcServer *grpc.Server, httpHandler http.Handler, relaySrv *relayServer.Server, meter metric.Meter, cfg *CombinedConfig) http.Handler { + wsProxy := wsproxyserver.New(grpcServer, wsproxyserver.WithOTelMeter(meter)) + + var relayAcceptFn func(conn net.Conn) + if relaySrv != nil { + relayAcceptFn = relaySrv.RelayAccept() + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + // Native gRPC traffic (HTTP/2 with gRPC content-type) + case r.ProtoMajor == 2 && (strings.HasPrefix(r.Header.Get("Content-Type"), "application/grpc") || + strings.HasPrefix(r.Header.Get("Content-Type"), "application/grpc+proto")): + grpcServer.ServeHTTP(w, r) + + // WebSocket proxy for Management gRPC + case r.URL.Path == wsproxy.ProxyPath+wsproxy.ManagementComponent: + wsProxy.Handler().ServeHTTP(w, r) + + // WebSocket proxy for Signal gRPC + case r.URL.Path == wsproxy.ProxyPath+wsproxy.SignalComponent: + if cfg.Signal.Enabled { + wsProxy.Handler().ServeHTTP(w, r) + } else { + http.Error(w, "Signal service not enabled", http.StatusNotFound) + } + + // Relay WebSocket + case r.URL.Path == "/relay": + if relayAcceptFn != nil { + handleRelayWebSocket(w, r, relayAcceptFn, cfg) + } else { + http.Error(w, "Relay service not enabled", http.StatusNotFound) + } + + // Management HTTP API (default) + default: + httpHandler.ServeHTTP(w, r) + } + }) +} + +// handleRelayWebSocket handles incoming WebSocket connections for the relay service +func handleRelayWebSocket(w http.ResponseWriter, r *http.Request, acceptFn func(conn net.Conn), cfg *CombinedConfig) { + acceptOptions := &websocket.AcceptOptions{ + OriginPatterns: []string{"*"}, + } + + wsConn, err := websocket.Accept(w, r, acceptOptions) + if err != nil { + log.Errorf("failed to accept relay ws connection: %s", err) + return + } + + connRemoteAddr := r.RemoteAddr + if r.Header.Get("X-Real-Ip") != "" && r.Header.Get("X-Real-Port") != "" { + connRemoteAddr = net.JoinHostPort(r.Header.Get("X-Real-Ip"), r.Header.Get("X-Real-Port")) + } + + rAddr, err := net.ResolveTCPAddr("tcp", connRemoteAddr) + if err != nil { + _ = wsConn.Close(websocket.StatusInternalError, "internal error") + return + } + + lAddr, err := net.ResolveTCPAddr("tcp", cfg.Server.ListenAddress) + if err != nil { + _ = wsConn.Close(websocket.StatusInternalError, "internal error") + return + } + + log.Debugf("Relay WS client connected from: %s", rAddr) + + conn := ws.NewConn(wsConn, lAddr, rAddr) + acceptFn(conn) +} + +// logConfig prints all configuration parameters for debugging +func logConfig(cfg *CombinedConfig) { + log.Info("=== Configuration ===") + logServerConfig(cfg) + logComponentsConfig(cfg) + logRelayConfig(cfg) + logManagementConfig(cfg) + log.Info("=== End Configuration ===") +} + +func logServerConfig(cfg *CombinedConfig) { + log.Info("--- Server ---") + log.Infof(" Listen address: %s", cfg.Server.ListenAddress) + log.Infof(" Exposed address: %s", cfg.Server.ExposedAddress) + log.Infof(" Healthcheck address: %s", cfg.Server.HealthcheckAddress) + log.Infof(" Metrics port: %d", cfg.Server.MetricsPort) + log.Infof(" Log level: %s", cfg.Server.LogLevel) + log.Infof(" Data dir: %s", cfg.Server.DataDir) + + switch { + case cfg.HasTLSCert(): + log.Infof(" TLS: cert=%s, key=%s", cfg.Server.TLS.CertFile, cfg.Server.TLS.KeyFile) + case cfg.HasLetsEncrypt(): + log.Infof(" TLS: Let's Encrypt (domains=%v)", cfg.Server.TLS.LetsEncrypt.Domains) + default: + log.Info(" TLS: disabled (using reverse proxy)") + } +} + +func logComponentsConfig(cfg *CombinedConfig) { + log.Info("--- Components ---") + log.Infof(" Management: %v (log level: %s)", cfg.Management.Enabled, cfg.Management.LogLevel) + log.Infof(" Signal: %v (log level: %s)", cfg.Signal.Enabled, cfg.Signal.LogLevel) + log.Infof(" Relay: %v (log level: %s)", cfg.Relay.Enabled, cfg.Relay.LogLevel) +} + +func logRelayConfig(cfg *CombinedConfig) { + if !cfg.Relay.Enabled { + return + } + log.Info("--- Relay ---") + log.Infof(" Exposed address: %s", cfg.Relay.ExposedAddress) + log.Infof(" Auth secret: %s...", maskSecret(cfg.Relay.AuthSecret)) + if cfg.Relay.Stun.Enabled { + log.Infof(" STUN ports: %v (log level: %s)", cfg.Relay.Stun.Ports, cfg.Relay.Stun.LogLevel) + } else { + log.Info(" STUN: disabled") + } +} + +func logManagementConfig(cfg *CombinedConfig) { + if !cfg.Management.Enabled { + return + } + log.Info("--- Management ---") + log.Infof(" Data dir: %s", cfg.Management.DataDir) + log.Infof(" DNS domain: %s", cfg.Management.DnsDomain) + log.Infof(" Store engine: %s", cfg.Management.Store.Engine) + if cfg.Server.Store.DSN != "" { + log.Infof(" Store DSN: %s", maskDSNPassword(cfg.Server.Store.DSN)) + } + + log.Info(" Auth (embedded IdP):") + log.Infof(" Issuer: %s", cfg.Management.Auth.Issuer) + log.Infof(" Dashboard redirect URIs: %v", cfg.Management.Auth.DashboardRedirectURIs) + log.Infof(" CLI redirect URIs: %v", cfg.Management.Auth.CLIRedirectURIs) + + log.Info(" Client settings:") + log.Infof(" Signal URI: %s", cfg.Management.SignalURI) + for _, s := range cfg.Management.Stuns { + log.Infof(" STUN: %s", s.URI) + } + if len(cfg.Management.Relays.Addresses) > 0 { + log.Infof(" Relay addresses: %v", cfg.Management.Relays.Addresses) + log.Infof(" Relay credentials TTL: %s", cfg.Management.Relays.CredentialsTTL) + } +} + +// logEnvVars logs all NB_ environment variables that are currently set +func logEnvVars() { + log.Info("=== Environment Variables ===") + found := false + for _, env := range os.Environ() { + if strings.HasPrefix(env, "NB_") { + key, _, _ := strings.Cut(env, "=") + value := os.Getenv(key) + keyLower := strings.ToLower(key) + if strings.Contains(keyLower, "secret") || strings.Contains(keyLower, "key") || strings.Contains(keyLower, "password") { + value = maskSecret(value) + } else if strings.Contains(keyLower, "dsn") { + value = maskDSNPassword(value) + } + log.Infof(" %s=%s", key, value) + found = true + } + } + if !found { + log.Info(" (none set)") + } + log.Info("=== End Environment Variables ===") +} + +// maskDSNPassword masks the password in a DSN string. +// Handles both key=value format ("password=secret") and URI format ("user:secret@host"). +func maskDSNPassword(dsn string) string { + // Key=value format: "host=localhost user=nb password=secret dbname=nb" + if strings.Contains(dsn, "password=") { + parts := strings.Fields(dsn) + for i, p := range parts { + if strings.HasPrefix(p, "password=") { + parts[i] = "password=****" + } + } + return strings.Join(parts, " ") + } + + // URI format: "user:password@host..." + if atIdx := strings.Index(dsn, "@"); atIdx != -1 { + prefix := dsn[:atIdx] + if colonIdx := strings.Index(prefix, ":"); colonIdx != -1 { + return prefix[:colonIdx+1] + "****" + dsn[atIdx:] + } + } + + return dsn +} + +// maskSecret returns first 4 chars of secret followed by "..." +func maskSecret(secret string) string { + if len(secret) <= 4 { + return "****" + } + return secret[:4] + "..." +} diff --git a/combined/cmd/token.go b/combined/cmd/token.go new file mode 100644 index 000000000..550480062 --- /dev/null +++ b/combined/cmd/token.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "github.com/netbirdio/netbird/formatter/hook" + tokencmd "github.com/netbirdio/netbird/management/cmd/token" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/util" +) + +// newTokenCommands creates the token command tree with combined-specific store opener. +func newTokenCommands() *cobra.Command { + return tokencmd.NewCommands(withTokenStore) +} + +// withTokenStore loads the combined YAML config, initializes the store, and calls fn. +func withTokenStore(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error { + if err := util.InitLog("error", "console"); err != nil { + return fmt.Errorf("init log: %w", err) + } + + ctx := context.WithValue(cmd.Context(), hook.ExecutionContextKey, hook.SystemSource) //nolint:staticcheck + + cfg, err := LoadConfig(configPath) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + if dsn := cfg.Server.Store.DSN; dsn != "" { + switch strings.ToLower(cfg.Server.Store.Engine) { + case "postgres": + os.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", dsn) + case "mysql": + os.Setenv("NB_STORE_ENGINE_MYSQL_DSN", dsn) + } + } + if file := cfg.Server.Store.File; file != "" { + os.Setenv("NB_STORE_ENGINE_SQLITE_FILE", file) + } + + datadir := cfg.Management.DataDir + engine := types.Engine(cfg.Management.Store.Engine) + + s, err := store.NewStore(ctx, engine, datadir, nil, true) + if err != nil { + return fmt.Errorf("create store: %w", err) + } + defer func() { + if err := s.Close(ctx); err != nil { + log.Debugf("close store: %v", err) + } + }() + + return fn(ctx, s) +} diff --git a/combined/config.yaml.example b/combined/config.yaml.example new file mode 100644 index 000000000..dce658d89 --- /dev/null +++ b/combined/config.yaml.example @@ -0,0 +1,124 @@ +# NetBird Combined Server Configuration +# Copy this file to config.yaml and customize for your deployment +# +# This is a Management server with optional embedded Signal, Relay, and STUN services. +# By default, all services run locally. You can use external services instead by +# setting the corresponding override fields. +# +# Architecture: +# - Management: Always runs locally (this IS the management server) +# - Signal: Local by default; set 'signalUri' to use external (disables local) +# - Relay: Local by default; set 'relays' to use external (disables local) +# - STUN: Local on port 3478 by default; set 'stuns' to use external instead + +server: + # Main HTTP/gRPC port for all services (Management, Signal, Relay) + listenAddress: ":443" + + # Public address that peers will use to connect to this server + # Used for relay connections and management DNS domain + # Format: protocol://hostname:port (e.g., https://server.mycompany.com:443) + exposedAddress: "https://server.mycompany.com:443" + + # STUN server ports (defaults to [3478] if not specified; set 'stuns' to use external) + # stunPorts: + # - 3478 + + # Metrics endpoint port + metricsPort: 9090 + + # Healthcheck endpoint address + healthcheckAddress: ":9000" + + # Logging configuration + logLevel: "info" # Default log level for all components: panic, fatal, error, warn, info, debug, trace + logFile: "console" # "console" or path to log file + + # TLS configuration (optional) + tls: + certFile: "" + keyFile: "" + letsencrypt: + enabled: false + dataDir: "" + domains: [] + email: "" + awsRoute53: false + + # Shared secret for relay authentication (required when running local relay) + authSecret: "your-secret-key-here" + + # Data directory for all services + dataDir: "/var/lib/netbird/" + + # ============================================================================ + # External Service Overrides (optional) + # Use these to point to external Signal, Relay, or STUN servers instead of + # running them locally. When set, the corresponding local service is disabled. + # ============================================================================ + + # External STUN servers - disables local STUN server + # stuns: + # - uri: "stun:stun.example.com:3478" + # - uri: "stun:stun.example.com:3479" + + # External relay servers - disables local relay server + # relays: + # addresses: + # - "rels://relay.example.com:443" + # credentialsTTL: "12h" + # secret: "relay-shared-secret" + + # External signal server - disables local signal server + # signalUri: "https://signal.example.com:443" + + # ============================================================================ + # Management Settings + # ============================================================================ + + # Metrics and updates + disableAnonymousMetrics: false + disableGeoliteUpdate: false + + # Embedded authentication/identity provider (Dex) configuration (always enabled) + auth: + # OIDC issuer URL - must be publicly accessible + issuer: "https://example.com/oauth2" + localAuthDisabled: false + signKeyRefreshEnabled: false + # OAuth2 redirect URIs for dashboard + dashboardRedirectURIs: + - "https://app.example.com/nb-auth" + - "https://app.example.com/nb-silent-auth" + # OAuth2 redirect URIs for CLI + cliRedirectURIs: + - "http://localhost:53000/" + # Optional initial admin user + # owner: + # email: "admin@example.com" + # password: "initial-password" + + # Store configuration + store: + engine: "sqlite" # sqlite, postgres, or mysql + dsn: "" # Connection string for postgres or mysql + encryptionKey: "" + # file: "" # Custom SQLite file path (optional, defaults to {dataDir}/store.db) + + # Activity events store configuration (optional, defaults to sqlite in dataDir) + # activityStore: + # engine: "sqlite" # sqlite or postgres + # dsn: "" # Connection string for postgres + # file: "" # Custom SQLite file path (optional, defaults to {dataDir}/events.db) + + # Auth (embedded IdP) store configuration (optional, defaults to sqlite3 in dataDir/idp.db) + # authStore: + # engine: "sqlite3" # sqlite3 or postgres + # dsn: "" # Connection string for postgres (e.g., "host=localhost port=5432 user=postgres password=postgres dbname=netbird_idp sslmode=disable") + # file: "" # Custom SQLite file path (optional, defaults to {dataDir}/idp.db) + + # Reverse proxy settings (optional) + # reverseProxy: + # trustedHTTPProxies: [] + # trustedHTTPProxiesCount: 0 + # trustedPeers: [] diff --git a/combined/main.go b/combined/main.go new file mode 100644 index 000000000..6740ac93e --- /dev/null +++ b/combined/main.go @@ -0,0 +1,13 @@ +package main + +import ( + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/combined/cmd" +) + +func main() { + if err := cmd.Execute(); err != nil { + log.Fatalf("failed to execute command: %v", err) + } +} diff --git a/formatter/txt/formatter.go b/formatter/txt/formatter.go index 3b2a3fb4d..4f174a740 100644 --- a/formatter/txt/formatter.go +++ b/formatter/txt/formatter.go @@ -1,8 +1,6 @@ package txt import ( - "time" - "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/formatter/levels" @@ -18,7 +16,7 @@ type TextFormatter struct { func NewTextFormatter() *TextFormatter { return &TextFormatter{ levelDesc: levels.ValidLevelDesc, - timestampFormat: time.RFC3339, // or RFC3339 + timestampFormat: "2006-01-02T15:04:05.000Z07:00", } } diff --git a/formatter/txt/formatter_test.go b/formatter/txt/formatter_test.go index 590af5d50..1b20a3ebf 100644 --- a/formatter/txt/formatter_test.go +++ b/formatter/txt/formatter_test.go @@ -21,6 +21,6 @@ func TestLogTextFormat(t *testing.T) { result, _ := formatter.Format(someEntry) parsedString := string(result) - expectedString := "^2021-02-21T01:10:30Z WARN \\[(att1: 1, att2: 2|att2: 2, att1: 1)\\] some/fancy/path.go:46: Some Message\\s+$" + expectedString := "^2021-02-21T01:10:30.000Z WARN \\[(att1: 1, att2: 2|att2: 2, att1: 1)\\] some/fancy/path.go:46: Some Message\\s+$" assert.Regexp(t, expectedString, parsedString) } diff --git a/go.mod b/go.mod index a284a6b4d..0a481a7af 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/netbirdio/netbird -go 1.25 - -toolchain go1.25.5 +go 1.25.5 require ( cunicu.li/go-rosenpass v0.4.0 @@ -40,8 +38,9 @@ require ( github.com/c-robinson/iplib v1.0.3 github.com/caddyserver/certmagic v0.21.3 github.com/cilium/ebpf v0.15.0 - github.com/coder/websocket v1.8.13 + github.com/coder/websocket v1.8.14 github.com/coreos/go-iptables v0.7.0 + github.com/coreos/go-oidc/v3 v3.14.1 github.com/creack/pty v1.1.24 github.com/dexidp/dex v0.0.0-00010101000000-000000000000 github.com/dexidp/dex/api/v2 v2.4.0 @@ -69,7 +68,7 @@ require ( github.com/mdlayher/socket v0.5.1 github.com/miekg/dns v1.1.59 github.com/mitchellh/hashstructure/v2 v2.0.2 - github.com/netbirdio/management-integrations/integrations v0.0.0-20260122111742-a6f99668844f + github.com/netbirdio/management-integrations/integrations v0.0.0-20260210160626-df4b180c7b25 github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 github.com/oapi-codegen/runtime v1.1.2 github.com/okta/okta-sdk-golang/v2 v2.18.0 @@ -83,6 +82,7 @@ require ( github.com/pion/stun/v3 v3.1.0 github.com/pion/transport/v3 v3.1.1 github.com/pion/turn/v3 v3.0.1 + github.com/pires/go-proxyproto v0.11.0 github.com/pkg/sftp v1.13.9 github.com/prometheus/client_golang v1.23.2 github.com/quic-go/quic-go v0.55.0 @@ -92,10 +92,10 @@ require ( github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 github.com/stretchr/testify v1.11.1 - github.com/testcontainers/testcontainers-go v0.31.0 - github.com/testcontainers/testcontainers-go/modules/mysql v0.31.0 - github.com/testcontainers/testcontainers-go/modules/postgres v0.31.0 - github.com/testcontainers/testcontainers-go/modules/redis v0.31.0 + github.com/testcontainers/testcontainers-go v0.37.0 + github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0 + github.com/testcontainers/testcontainers-go/modules/redis v0.37.0 github.com/things-go/go-socks5 v0.0.4 github.com/ti-mo/conntrack v0.5.1 github.com/ti-mo/netfilter v0.5.2 @@ -124,7 +124,7 @@ require ( gorm.io/driver/postgres v1.5.7 gorm.io/driver/sqlite v1.5.7 gorm.io/gorm v1.25.12 - gvisor.dev/gvisor v0.0.0-20251031020517-ecfcdd2f171c + gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89 ) require ( @@ -141,7 +141,6 @@ require ( github.com/Masterminds/semver/v3 v3.3.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/Microsoft/hcsshim v0.12.3 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/awnumar/memcall v0.4.0 // indirect @@ -165,17 +164,16 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/caddyserver/zerossl v0.1.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/containerd/containerd v1.7.29 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect - github.com/coreos/go-oidc/v3 v3.14.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/docker v26.1.5+incompatible // indirect + github.com/docker/docker v28.0.1+incompatible // indirect github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.8.2 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fredbi/uri v1.1.1 // indirect github.com/fyne-io/gl-js v0.2.0 // indirect @@ -224,9 +222,10 @@ require ( github.com/lib/pq v1.10.9 // indirect github.com/libdns/libdns v0.2.2 // indirect github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae // indirect - github.com/magiconair/properties v1.8.7 // indirect + github.com/magiconair/properties v1.8.10 // indirect github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect github.com/mattn/go-sqlite3 v1.14.32 // indirect + github.com/mdelapenya/tlscert v0.2.0 // indirect github.com/mdlayher/genetlink v1.3.2 // indirect github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect github.com/mholt/acmez/v2 v2.0.1 // indirect @@ -245,7 +244,7 @@ require ( github.com/nxadm/tail v1.4.8 // indirect github.com/onsi/ginkgo/v2 v2.9.5 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.1.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pion/dtls/v2 v2.2.10 // indirect github.com/pion/dtls/v3 v3.0.9 // indirect github.com/pion/mdns/v2 v2.0.7 // indirect @@ -259,6 +258,7 @@ require ( github.com/prometheus/procfs v0.16.1 // indirect github.com/russellhaering/goxmldsig v1.5.0 // indirect github.com/rymdport/portal v0.4.2 // indirect + github.com/shirou/gopsutil/v4 v4.25.1 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/cast v1.7.0 // indirect diff --git a/go.sum b/go.sum index f1e45108c..9fbc2ce0a 100644 --- a/go.sum +++ b/go.sum @@ -33,8 +33,6 @@ github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/Microsoft/hcsshim v0.12.3 h1:LS9NXqXhMoqNCplK1ApmVSfB4UnVLRDWRapB6EIlxE0= -github.com/Microsoft/hcsshim v0.12.3/go.mod h1:Iyl1WVpZzr+UkzjekHZbV8o5Z9ZkxNGx6CtY2Qg/JVQ= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/TheJumpCloud/jcapi-go v3.0.0+incompatible h1:hqcTK6ZISdip65SR792lwYJTa/axESA0889D3UlZbLo= github.com/TheJumpCloud/jcapi-go v3.0.0+incompatible/go.mod h1:6B1nuc1MUs6c62ODZDl7hVE5Pv7O2XGSkgg2olnq34I= @@ -107,10 +105,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk= github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso= -github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= -github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= -github.com/containerd/containerd v1.7.29 h1:90fWABQsaN9mJhGkoVnuzEY+o1XDPbg9BTC9QTAHnuE= -github.com/containerd/containerd v1.7.29/go.mod h1:azUkWcOvHrWvaiUjSQH0fjzuHIwSPg1WL5PshGP4Szs= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= @@ -135,12 +131,14 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/docker v26.1.5+incompatible h1:NEAxTwEjxV6VbBMBoGG3zPqbiJosIApZjxlbrG9q3/g= -github.com/docker/docker v26.1.5+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v28.0.1+incompatible h1:FCHjSRdXhNRFjlHMTv4jUNlIBbTeRjrWfeFuJp7jpo0= +github.com/docker/docker v28.0.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I= +github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/eko/gocache/lib/v4 v4.2.0 h1:MNykyi5Xw+5Wu3+PUrvtOCaKSZM1nUSVftbzmeC7Yuw= github.com/eko/gocache/lib/v4 v4.2.0/go.mod h1:7ViVmbU+CzDHzRpmB4SXKyyzyuJ8A3UW3/cszpcqB4M= github.com/eko/gocache/store/go_cache/v4 v4.2.2 h1:tAI9nl6TLoJyKG1ujF0CS0n/IgTEMl+NivxtR5R3/hw= @@ -195,8 +193,6 @@ github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3yg github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= -github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= -github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= @@ -365,13 +361,15 @@ github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81/go.mod h1:RD8ML/Y github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae h1:dIZY4ULFcto4tAFlj1FYZl8ztUZ13bdq+PLY+NOfbyI= github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= -github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= @@ -414,8 +412,8 @@ github.com/netbirdio/go-netroute v0.0.0-20240611143515-f59b0e1d3944 h1:TDtJKmM6S github.com/netbirdio/go-netroute v0.0.0-20240611143515-f59b0e1d3944/go.mod h1:sHA6TRxjQ6RLbnI+3R4DZo2Eseg/iKiPRfNmcuNySVQ= github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51 h1:Ov4qdafATOgGMB1wbSuh+0aAHcwz9hdvB6VZjh1mVMI= github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51/go.mod h1:ZSIbPdBn5hePO8CpF1PekH2SfpTxg1PDhEwtbqZS7R8= -github.com/netbirdio/management-integrations/integrations v0.0.0-20260122111742-a6f99668844f h1:CTBf0je/FpKr2lVSMZLak7m8aaWcS6ur4SOfhSSazFI= -github.com/netbirdio/management-integrations/integrations v0.0.0-20260122111742-a6f99668844f/go.mod h1:y7CxagMYzg9dgu+masRqYM7BQlOGA5Y8US85MCNFPlY= +github.com/netbirdio/management-integrations/integrations v0.0.0-20260210160626-df4b180c7b25 h1:iwAq/Ncaq0etl4uAlVsbNBzC1yY52o0AmY7uCm2AMTs= +github.com/netbirdio/management-integrations/integrations v0.0.0-20260210160626-df4b180c7b25/go.mod h1:y7CxagMYzg9dgu+masRqYM7BQlOGA5Y8US85MCNFPlY= github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9axERMVN63dqyFqnvuD+EMJHzM7mNGON8= github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM= github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ= @@ -445,13 +443,12 @@ github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= -github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/oschwald/maxminddb-golang v1.12.0 h1:9FnTOD0YOhP7DGxGsq4glzpGy5+w7pq50AS6wALUMYs= github.com/oschwald/maxminddb-golang v1.12.0/go.mod h1:q0Nob5lTCqyQ8WT6FYgS1L7PXKVVbgiymefNwIjPzgY= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= -github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0= github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 h1:E7Kmf11E4K7B5hDti2K2NqPb1nlYlGYsu02S1JNd/Bs= @@ -482,6 +479,8 @@ github.com/pion/turn/v3 v3.0.1 h1:wLi7BTQr6/Q20R0vt/lHbjv6y4GChFtC33nkYbasoT8= github.com/pion/turn/v3 v3.0.1/go.mod h1:MrJDKgqryDyWy1/4NT9TWfXWGMC7UHT6pJIv1+gMeNE= github.com/pion/turn/v4 v4.1.1 h1:9UnY2HB99tpDyz3cVVZguSxcqkJ1DsTSZ+8TGruh4fc= github.com/pion/turn/v4 v4.1.1/go.mod h1:2123tHk1O++vmjI5VSD0awT50NywDAq5A2NNNU4Jjs8= +github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4= +github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -519,6 +518,8 @@ github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= github.com/shirou/gopsutil/v3 v3.24.4 h1:dEHgzZXt4LMNm+oYELpzl9YCqV65Yr/6SfrvgRBtXeU= github.com/shirou/gopsutil/v3 v3.24.4/go.mod h1:lTd2mdiOspcqLgAnr9/nGi71NkeMpWKdmhuxm9GusH8= +github.com/shirou/gopsutil/v4 v4.25.1 h1:QSWkTc+fu9LTAWfkZwZ6j8MSUk4A2LV7rbH0ZqmLjXs= +github.com/shirou/gopsutil/v4 v4.25.1/go.mod h1:RoUCUpndaJFtT+2zsZzzmhvbfGoDCJ7nFXKJf8GqJbI= github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= @@ -560,14 +561,14 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/testcontainers/testcontainers-go v0.31.0 h1:W0VwIhcEVhRflwL9as3dhY6jXjVCA27AkmbnZ+UTh3U= -github.com/testcontainers/testcontainers-go v0.31.0/go.mod h1:D2lAoA0zUFiSY+eAflqK5mcUx/A5hrrORaEQrd0SefI= -github.com/testcontainers/testcontainers-go/modules/mysql v0.31.0 h1:790+S8ewZYCbG+o8IiFlZ8ZZ33XbNO6zV9qhU6xhlRk= -github.com/testcontainers/testcontainers-go/modules/mysql v0.31.0/go.mod h1:REFmO+lSG9S6uSBEwIMZCxeI36uhScjTwChYADeO3JA= -github.com/testcontainers/testcontainers-go/modules/postgres v0.31.0 h1:isAwFS3KNKRbJMbWv+wolWqOFUECmjYZ+sIRZCIBc/E= -github.com/testcontainers/testcontainers-go/modules/postgres v0.31.0/go.mod h1:ZNYY8vumNCEG9YI59A9d6/YaMY49uwRhmeU563EzFGw= -github.com/testcontainers/testcontainers-go/modules/redis v0.31.0 h1:5X6GhOdLwV86zcW8sxppJAMtsDC9u+r9tb3biBc9GKs= -github.com/testcontainers/testcontainers-go/modules/redis v0.31.0/go.mod h1:dKi5xBwy1k4u8yb3saQHu7hMEJwewHXxzbcMAuLiA6o= +github.com/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw7Z/PtHS/QzZZ5Ra/hg= +github.com/testcontainers/testcontainers-go v0.37.0/go.mod h1:QPzbxZhQ6Bclip9igjLFj6z0hs01bU8lrl2dHQmgFGM= +github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0 h1:LqUos1oR5iuuzorFnSvxsHNdYdCHB/DfI82CuT58wbI= +github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0/go.mod h1:vHEEHx5Kf+uq5hveaVAMrTzPY8eeRZcKcl23MRw5Tkc= +github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0 h1:hsVwFkS6s+79MbKEO+W7A1wNIw1fmkMtF4fg83m6kbc= +github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0/go.mod h1:Qj/eGbRbO/rEYdcRLmN+bEojzatP/+NS1y8ojl2PQsc= +github.com/testcontainers/testcontainers-go/modules/redis v0.37.0 h1:9HIY28I9ME/Zmb+zey1p/I1mto5+5ch0wLX+nJdOsQ4= +github.com/testcontainers/testcontainers-go/modules/redis v0.37.0/go.mod h1:Abu9g/25Qv+FkYVx3U4Voaynou1c+7D0HIhaQJXvk6E= github.com/things-go/go-socks5 v0.0.4 h1:jMQjIc+qhD4z9cITOMnBiwo9dDmpGuXmBlkRFrl/qD0= github.com/things-go/go-socks5 v0.0.4/go.mod h1:sh4K6WHrmHZpjxLTCHyYtXYH8OUuD+yZun41NomR1IQ= github.com/ti-mo/conntrack v0.5.1 h1:opEwkFICnDbQc0BUXl73PHBK0h23jEIFVjXsqvF4GY0= @@ -857,7 +858,7 @@ gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDa gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= -gotest.tools/v3 v3.5.0 h1:Ljk6PdHdOhAb5aDMWXjDLMMhph+BpztA4v1QdqEW2eY= -gotest.tools/v3 v3.5.0/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= -gvisor.dev/gvisor v0.0.0-20251031020517-ecfcdd2f171c h1:pfzmXIkkDgydR4ZRP+e1hXywZfYR21FA0Fbk6ptMkiA= -gvisor.dev/gvisor v0.0.0-20251031020517-ecfcdd2f171c/go.mod h1:/mc6CfwbOm5KKmqoV7Qx20Q+Ja8+vO4g7FuCdlVoAfQ= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89 h1:mGJaeA61P8dEHTqdvAgc70ZIV3QoUoJcXCRyyjO26OA= +gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q= diff --git a/idp/dex/config.go b/idp/dex/config.go index 57f832406..3db04a4cb 100644 --- a/idp/dex/config.go +++ b/idp/dex/config.go @@ -5,7 +5,10 @@ import ( "encoding/json" "fmt" "log/slog" + "net/url" "os" + "strconv" + "strings" "time" "golang.org/x/crypto/bcrypt" @@ -195,11 +198,175 @@ func (s *Storage) OpenStorage(logger *slog.Logger) (storage.Storage, error) { return nil, fmt.Errorf("sqlite3 storage requires 'file' config") } return (&sql.SQLite3{File: file}).Open(logger) + case "postgres": + dsn, _ := s.Config["dsn"].(string) + if dsn == "" { + return nil, fmt.Errorf("postgres storage requires 'dsn' config") + } + pg, err := parsePostgresDSN(dsn) + if err != nil { + return nil, fmt.Errorf("invalid postgres DSN: %w", err) + } + return pg.Open(logger) default: return nil, fmt.Errorf("unsupported storage type: %s", s.Type) } } +// parsePostgresDSN parses a DSN into a sql.Postgres config. +// It accepts both URI format (postgres://user:pass@host:port/dbname?sslmode=disable) +// and libpq key=value format (host=localhost port=5432 dbname=mydb), including quoted values. +func parsePostgresDSN(dsn string) (*sql.Postgres, error) { + var params map[string]string + var err error + + if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") { + params, err = parsePostgresURI(dsn) + } else { + params, err = parsePostgresKeyValue(dsn) + } + if err != nil { + return nil, err + } + + host := params["host"] + if host == "" { + host = "localhost" + } + + var port uint16 = 5432 + if p, ok := params["port"]; ok && p != "" { + v, err := strconv.ParseUint(p, 10, 16) + if err != nil { + return nil, fmt.Errorf("invalid port %q: %w", p, err) + } + if v == 0 { + return nil, fmt.Errorf("invalid port %q: must be non-zero", p) + } + port = uint16(v) + } + + dbname := params["dbname"] + if dbname == "" { + return nil, fmt.Errorf("dbname is required in DSN") + } + + pg := &sql.Postgres{ + NetworkDB: sql.NetworkDB{ + Host: host, + Port: port, + Database: dbname, + User: params["user"], + Password: params["password"], + }, + } + + if sslMode := params["sslmode"]; sslMode != "" { + switch sslMode { + case "disable", "allow", "prefer", "require", "verify-ca", "verify-full": + pg.SSL.Mode = sslMode + default: + return nil, fmt.Errorf("unsupported sslmode %q: valid values are disable, allow, prefer, require, verify-ca, verify-full", sslMode) + } + } + + return pg, nil +} + +// parsePostgresURI parses a postgres:// or postgresql:// URI into parameter key-value pairs. +func parsePostgresURI(dsn string) (map[string]string, error) { + u, err := url.Parse(dsn) + if err != nil { + return nil, fmt.Errorf("invalid postgres URI: %w", err) + } + + params := make(map[string]string) + + if u.User != nil { + params["user"] = u.User.Username() + if p, ok := u.User.Password(); ok { + params["password"] = p + } + } + if u.Hostname() != "" { + params["host"] = u.Hostname() + } + if u.Port() != "" { + params["port"] = u.Port() + } + + dbname := strings.TrimPrefix(u.Path, "/") + if dbname != "" { + params["dbname"] = dbname + } + + for k, v := range u.Query() { + if len(v) > 0 { + params[k] = v[0] + } + } + + return params, nil +} + +// parsePostgresKeyValue parses a libpq key=value DSN string, handling single-quoted values +// (e.g., password='my pass' host=localhost). +func parsePostgresKeyValue(dsn string) (map[string]string, error) { + params := make(map[string]string) + s := strings.TrimSpace(dsn) + + for s != "" { + eqIdx := strings.IndexByte(s, '=') + if eqIdx < 0 { + break + } + key := strings.TrimSpace(s[:eqIdx]) + + value, rest, err := parseDSNValue(s[eqIdx+1:]) + if err != nil { + return nil, fmt.Errorf("%w for key %q", err, key) + } + + params[key] = value + s = strings.TrimSpace(rest) + } + + return params, nil +} + +// parseDSNValue parses the next value from a libpq key=value string positioned after the '='. +// It returns the parsed value and the remaining unparsed string. +func parseDSNValue(s string) (value, rest string, err error) { + if len(s) > 0 && s[0] == '\'' { + return parseQuotedDSNValue(s[1:]) + } + // Unquoted value: read until whitespace. + idx := strings.IndexAny(s, " \t\n") + if idx < 0 { + return s, "", nil + } + return s[:idx], s[idx:], nil +} + +// parseQuotedDSNValue parses a single-quoted value starting after the opening quote. +// Libpq uses ” to represent a literal single quote inside quoted values. +func parseQuotedDSNValue(s string) (value, rest string, err error) { + var buf strings.Builder + for len(s) > 0 { + if s[0] == '\'' { + if len(s) > 1 && s[1] == '\'' { + buf.WriteByte('\'') + s = s[2:] + continue + } + return buf.String(), s[1:], nil + } + buf.WriteByte(s[0]) + s = s[1:] + } + return "", "", fmt.Errorf("unterminated quoted value") +} + // Validate validates the configuration func (c *YAMLConfig) Validate() error { if c.Issuer == "" { diff --git a/idp/dex/connector.go b/idp/dex/connector.go index cad682141..ba2bb1f00 100644 --- a/idp/dex/connector.go +++ b/idp/dex/connector.go @@ -327,6 +327,60 @@ func ensureLocalConnector(ctx context.Context, stor storage.Storage) error { return nil } +// HasNonLocalConnectors checks if there are any connectors other than the local connector. +func (p *Provider) HasNonLocalConnectors(ctx context.Context) (bool, error) { + connectors, err := p.storage.ListConnectors(ctx) + if err != nil { + return false, fmt.Errorf("failed to list connectors: %w", err) + } + + p.logger.Info("checking for non-local connectors", "total_connectors", len(connectors)) + for _, conn := range connectors { + p.logger.Info("found connector in storage", "id", conn.ID, "type", conn.Type, "name", conn.Name) + if conn.ID != "local" || conn.Type != "local" { + p.logger.Info("found non-local connector", "id", conn.ID) + return true, nil + } + } + p.logger.Info("no non-local connectors found") + return false, nil +} + +// DisableLocalAuth removes the local (password) connector. +// Returns an error if no other connectors are configured. +func (p *Provider) DisableLocalAuth(ctx context.Context) error { + hasOthers, err := p.HasNonLocalConnectors(ctx) + if err != nil { + return err + } + if !hasOthers { + return fmt.Errorf("cannot disable local authentication: no other identity providers configured") + } + + // Check if local connector exists + _, err = p.storage.GetConnector(ctx, "local") + if errors.Is(err, storage.ErrNotFound) { + // Already disabled + return nil + } + if err != nil { + return fmt.Errorf("failed to check local connector: %w", err) + } + + // Delete the local connector + if err := p.storage.DeleteConnector(ctx, "local"); err != nil { + return fmt.Errorf("failed to delete local connector: %w", err) + } + + p.logger.Info("local authentication disabled") + return nil +} + +// EnableLocalAuth creates the local (password) connector if it doesn't exist. +func (p *Provider) EnableLocalAuth(ctx context.Context) error { + return ensureLocalConnector(ctx, p.storage) +} + // ensureStaticConnectors creates or updates static connectors in storage func ensureStaticConnectors(ctx context.Context, stor storage.Storage, connectors []Connector) error { for _, conn := range connectors { diff --git a/idp/dex/provider.go b/idp/dex/provider.go index 6c608dbf5..68fe48486 100644 --- a/idp/dex/provider.go +++ b/idp/dex/provider.go @@ -99,15 +99,16 @@ func NewProvider(ctx context.Context, config *Config) (*Provider, error) { // Build Dex server config - use Dex's types directly dexConfig := server.Config{ - Issuer: issuer, - Storage: stor, - SkipApprovalScreen: true, - SupportedResponseTypes: []string{"code"}, - Logger: logger, - PrometheusRegistry: prometheus.NewRegistry(), - RotateKeysAfter: 6 * time.Hour, - IDTokensValidFor: 24 * time.Hour, - RefreshTokenPolicy: refreshPolicy, + Issuer: issuer, + Storage: stor, + SkipApprovalScreen: true, + SupportedResponseTypes: []string{"code"}, + ContinueOnConnectorFailure: true, + Logger: logger, + PrometheusRegistry: prometheus.NewRegistry(), + RotateKeysAfter: 6 * time.Hour, + IDTokensValidFor: 24 * time.Hour, + RefreshTokenPolicy: refreshPolicy, Web: server.WebConfig{ Issuer: "NetBird", }, @@ -260,6 +261,7 @@ func buildDexConfig(yamlConfig *YAMLConfig, stor storage.Storage, logger *slog.L if len(cfg.SupportedResponseTypes) == 0 { cfg.SupportedResponseTypes = []string{"code"} } + cfg.ContinueOnConnectorFailure = true return cfg } diff --git a/idp/dex/provider_test.go b/idp/dex/provider_test.go index bc34e592f..bd2f676fb 100644 --- a/idp/dex/provider_test.go +++ b/idp/dex/provider_test.go @@ -2,6 +2,7 @@ package dex import ( "context" + "log/slog" "os" "path/filepath" "testing" @@ -195,3 +196,64 @@ enablePasswordDB: true t.Logf("User lookup successful: rawID=%s, connectorID=%s", rawID, connID) } + +func TestNewProvider_ContinueOnConnectorFailure(t *testing.T) { + ctx := context.Background() + + tmpDir, err := os.MkdirTemp("", "dex-connector-failure-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + config := &Config{ + Issuer: "http://localhost:5556/dex", + Port: 5556, + DataDir: tmpDir, + } + + provider, err := NewProvider(ctx, config) + require.NoError(t, err) + defer func() { _ = provider.Stop(ctx) }() + + // The provider should have started successfully even though + // ContinueOnConnectorFailure is an internal Dex config field. + // We verify the provider is functional by performing a basic operation. + assert.NotNil(t, provider.dexServer) + assert.NotNil(t, provider.storage) +} + +func TestBuildDexConfig_ContinueOnConnectorFailure(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "dex-build-config-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + yamlContent := ` +issuer: http://localhost:5556/dex +storage: + type: sqlite3 + config: + file: ` + filepath.Join(tmpDir, "dex.db") + ` +web: + http: 127.0.0.1:5556 +enablePasswordDB: true +` + configPath := filepath.Join(tmpDir, "config.yaml") + err = os.WriteFile(configPath, []byte(yamlContent), 0644) + require.NoError(t, err) + + yamlConfig, err := LoadConfig(configPath) + require.NoError(t, err) + + ctx := context.Background() + stor, err := yamlConfig.Storage.OpenStorage(slog.New(slog.NewTextHandler(os.Stderr, nil))) + require.NoError(t, err) + defer stor.Close() + + err = initializeStorage(ctx, stor, yamlConfig) + require.NoError(t, err) + + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + cfg := buildDexConfig(yamlConfig, stor, logger) + + assert.True(t, cfg.ContinueOnConnectorFailure, + "buildDexConfig must set ContinueOnConnectorFailure to true so management starts even if an external IdP is down") +} diff --git a/infrastructure_files/getting-started.sh b/infrastructure_files/getting-started.sh index 25599997c..70088d66a 100755 --- a/infrastructure_files/getting-started.sh +++ b/infrastructure_files/getting-started.sh @@ -85,8 +85,8 @@ read_nb_domain() { read_reverse_proxy_type() { echo "" > /dev/stderr echo "Which reverse proxy will you use?" > /dev/stderr - echo " [0] Built-in Caddy (recommended - automatic TLS)" > /dev/stderr - echo " [1] Traefik (labels added to containers)" > /dev/stderr + echo " [0] Traefik (recommended - automatic TLS, included in Docker Compose)" > /dev/stderr + echo " [1] Existing Traefik (labels for external Traefik instance)" > /dev/stderr echo " [2] Nginx (generates config template)" > /dev/stderr echo " [3] Nginx Proxy Manager (generates config + instructions)" > /dev/stderr echo " [4] External Caddy (generates Caddyfile snippet)" > /dev/stderr @@ -166,6 +166,36 @@ read_proxy_docker_network() { return 0 } +read_enable_proxy() { + echo "" > /dev/stderr + echo "Do you want to enable the NetBird Proxy service?" > /dev/stderr + echo "The proxy allows you to selectively expose internal NetBird network resources" > /dev/stderr + echo "to the internet. You control which resources are exposed through the dashboard." > /dev/stderr + echo -n "Enable proxy? [y/N]: " > /dev/stderr + read -r CHOICE < /dev/tty + + if [[ "$CHOICE" =~ ^[Yy]$ ]]; then + echo "true" + else + echo "false" + fi + return 0 +} + +read_traefik_acme_email() { + echo "" > /dev/stderr + echo "Enter your email for Let's Encrypt certificate notifications." > /dev/stderr + echo -n "Email address: " > /dev/stderr + read -r EMAIL < /dev/tty + if [[ -z "$EMAIL" ]]; then + echo "Email is required for Let's Encrypt." > /dev/stderr + read_traefik_acme_email + return + fi + echo "$EMAIL" + return 0 +} + get_bind_address() { if [[ "$BIND_LOCALHOST_ONLY" == "true" ]]; then echo "127.0.0.1" @@ -182,20 +212,21 @@ get_upstream_host() { return 0 } -wait_management() { +wait_management_proxy() { + local proxy_container="${1:-traefik}" set +e - echo -n "Waiting for Management server to become ready" + echo -n "Waiting for NetBird server to become ready" counter=1 while true; do - # Check the embedded IdP endpoint + # Check the embedded IdP endpoint through the reverse proxy if curl -sk -f -o /dev/null "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/oauth2/.well-known/openid-configuration" 2>/dev/null; then break fi if [[ $counter -eq 60 ]]; then echo "" echo "Taking too long. Checking logs..." - $DOCKER_COMPOSE_COMMAND logs --tail=20 caddy - $DOCKER_COMPOSE_COMMAND logs --tail=20 management + $DOCKER_COMPOSE_COMMAND logs --tail=20 "$proxy_container" + $DOCKER_COMPOSE_COMMAND logs --tail=20 netbird-server fi echo -n " ." sleep 2 @@ -209,7 +240,7 @@ wait_management() { wait_management_direct() { set +e local upstream_host=$(get_upstream_host) - echo -n "Waiting for Management server to become ready" + echo -n "Waiting for NetBird server to become ready" counter=1 while true; do # Check the embedded IdP endpoint directly (no reverse proxy) @@ -219,7 +250,7 @@ wait_management_direct() { if [[ $counter -eq 60 ]]; then echo "" echo "Taking too long. Checking logs..." - $DOCKER_COMPOSE_COMMAND logs --tail=20 management + $DOCKER_COMPOSE_COMMAND logs --tail=20 netbird-server fi echo -n " ." sleep 2 @@ -235,7 +266,6 @@ wait_management_direct() { ############################################ initialize_default_values() { - CADDY_SECURE_DOMAIN="" NETBIRD_PORT=80 NETBIRD_HTTP_PROTOCOL="http" NETBIRD_RELAY_PROTO="rel" @@ -245,24 +275,28 @@ initialize_default_values() { NETBIRD_STUN_PORT=3478 # Docker images - CADDY_IMAGE="caddy" DASHBOARD_IMAGE="netbirdio/dashboard:latest" - SIGNAL_IMAGE="netbirdio/signal:latest" - RELAY_IMAGE="netbirdio/relay:latest" - MANAGEMENT_IMAGE="netbirdio/management:latest" + # Combined server replaces separate signal, relay, and management containers + NETBIRD_SERVER_IMAGE="netbirdio/netbird-server:latest" + NETBIRD_PROXY_IMAGE="netbirdio/reverse-proxy:latest" # Reverse proxy configuration REVERSE_PROXY_TYPE="0" TRAEFIK_EXTERNAL_NETWORK="" TRAEFIK_ENTRYPOINT="websecure" TRAEFIK_CERTRESOLVER="" + TRAEFIK_ACME_EMAIL="" DASHBOARD_HOST_PORT="8080" - MANAGEMENT_HOST_PORT="8081" - SIGNAL_HOST_PORT="8083" - SIGNAL_GRPC_PORT="10000" - RELAY_HOST_PORT="8084" + MANAGEMENT_HOST_PORT="8081" # Combined server port (management + signal + relay) BIND_LOCALHOST_ONLY="true" EXTERNAL_PROXY_NETWORK="" + + # Traefik static IP within the internal bridge network + TRAEFIK_IP="172.30.0.10" + + # NetBird Proxy configuration + ENABLE_PROXY="false" + PROXY_TOKEN="" return 0 } @@ -273,11 +307,12 @@ configure_domain() { if [[ "$NETBIRD_DOMAIN" == "use-ip" ]]; then NETBIRD_DOMAIN=$(get_main_ip_address) + BASE_DOMAIN=$NETBIRD_DOMAIN else NETBIRD_PORT=443 - CADDY_SECURE_DOMAIN=", $NETBIRD_DOMAIN:$NETBIRD_PORT" NETBIRD_HTTP_PROTOCOL="https" NETBIRD_RELAY_PROTO="rels" + BASE_DOMAIN=$(echo $NETBIRD_DOMAIN | sed -E 's/^[^.]+\.//') fi return 0 } @@ -286,7 +321,13 @@ configure_reverse_proxy() { # Prompt for reverse proxy type REVERSE_PROXY_TYPE=$(read_reverse_proxy_type) - # Handle Traefik-specific prompts + # Handle built-in Traefik prompts (option 0) + if [[ "$REVERSE_PROXY_TYPE" == "0" ]]; then + TRAEFIK_ACME_EMAIL=$(read_traefik_acme_email) + ENABLE_PROXY=$(read_enable_proxy) + fi + + # Handle external Traefik-specific prompts (option 1) if [[ "$REVERSE_PROXY_TYPE" == "1" ]]; then TRAEFIK_EXTERNAL_NETWORK=$(read_traefik_network) TRAEFIK_ENTRYPOINT=$(read_traefik_entrypoint) @@ -309,11 +350,11 @@ configure_reverse_proxy() { } check_existing_installation() { - if [[ -f management.json ]]; then + if [[ -f config.yaml ]]; then echo "Generated files already exist, if you want to reinitialize the environment, please remove them first." echo "You can use the following commands:" echo " $DOCKER_COMPOSE_COMMAND down --volumes # to remove all containers and volumes" - echo " rm -f docker-compose.yml Caddyfile dashboard.env management.json relay.env nginx-netbird.conf caddyfile-netbird.txt npm-advanced-config.txt" + echo " rm -f docker-compose.yml dashboard.env config.yaml proxy.env traefik-dynamic.yaml nginx-netbird.conf caddyfile-netbird.txt npm-advanced-config.txt" echo "Be aware that this will remove all data from the database, and you will have to reconfigure the dashboard." exit 1 fi @@ -326,8 +367,15 @@ generate_configuration_files() { # Render docker-compose and proxy config based on selection case "$REVERSE_PROXY_TYPE" in 0) - render_docker_compose > docker-compose.yml - render_caddyfile > Caddyfile + render_docker_compose_traefik_builtin > docker-compose.yml + if [[ "$ENABLE_PROXY" == "true" ]]; then + # Create placeholder proxy.env so docker-compose can validate + # This will be overwritten with the actual token after netbird-server starts + echo "# Placeholder - will be updated with token after netbird-server starts" > proxy.env + echo "NB_PROXY_TOKEN=placeholder" >> proxy.env + # TCP ServersTransport for PROXY protocol v2 to the proxy backend + render_traefik_dynamic > traefik-dynamic.yaml + fi ;; 1) render_docker_compose_traefik > docker-compose.yml @@ -355,27 +403,59 @@ generate_configuration_files() { # Common files for all configurations render_dashboard_env > dashboard.env - render_management_json > management.json - render_relay_env > relay.env + render_combined_yaml > config.yaml return 0 } start_services_and_show_instructions() { - # For built-in Caddy and Traefik, start containers immediately + # For built-in Traefik, start containers immediately # For NPM, start containers first (NPM needs services running to create proxy) # For other external proxies, show instructions first and wait for user confirmation if [[ "$REVERSE_PROXY_TYPE" == "0" ]]; then - # Built-in Caddy - handles everything automatically + # Built-in Traefik - two-phase startup if proxy is enabled echo -e "$MSG_STARTING_SERVICES" - $DOCKER_COMPOSE_COMMAND up -d - sleep 3 - wait_management + if [[ "$ENABLE_PROXY" == "true" ]]; then + # Phase 1: Start core services (without proxy) + echo "Starting core services..." + $DOCKER_COMPOSE_COMMAND up -d traefik dashboard netbird-server + + sleep 3 + wait_management_proxy traefik + + # Phase 2: Create proxy token and start proxy + echo "" + echo "Creating proxy access token..." + # Use docker exec with bash to run the token command directly + PROXY_TOKEN=$($DOCKER_COMPOSE_COMMAND exec -T netbird-server \ + /go/bin/netbird-server token create --name "default-proxy" --config /etc/netbird/config.yaml 2>/dev/null | grep "^Token:" | awk '{print $2}') + + if [[ -z "$PROXY_TOKEN" ]]; then + echo "ERROR: Failed to create proxy token. Check netbird-server logs." > /dev/stderr + $DOCKER_COMPOSE_COMMAND logs --tail=20 netbird-server + exit 1 + fi + + echo "Proxy token created successfully." + + # Generate proxy.env with the token + render_proxy_env > proxy.env + + # Start proxy service + echo "Starting proxy service..." + $DOCKER_COMPOSE_COMMAND up -d proxy + else + # No proxy - start all services at once + $DOCKER_COMPOSE_COMMAND up -d + + sleep 3 + wait_management_proxy traefik + fi echo -e "$MSG_DONE" print_post_setup_instructions elif [[ "$REVERSE_PROXY_TYPE" == "1" ]]; then - # Traefik - start containers first, then show instructions + # External Traefik - start containers, then show instructions # Traefik discovers services via Docker labels, so containers must be running echo -e "$MSG_STARTING_SERVICES" $DOCKER_COMPOSE_COMMAND up -d @@ -441,73 +521,210 @@ init_environment() { # Configuration File Renderers ############################################ -render_caddyfile() { +render_docker_compose_traefik_builtin() { + # Generate proxy service section and Traefik dynamic config if enabled + local proxy_service="" + local proxy_volumes="" + local traefik_file_provider="" + local traefik_dynamic_volume="" + if [[ "$ENABLE_PROXY" == "true" ]]; then + traefik_file_provider=' - "--providers.file.filename=/etc/traefik/dynamic.yaml"' + traefik_dynamic_volume=" - ./traefik-dynamic.yaml:/etc/traefik/dynamic.yaml:ro" + proxy_service=" + # NetBird Proxy - exposes internal resources to the internet + proxy: + image: $NETBIRD_PROXY_IMAGE + container_name: netbird-proxy + ports: + - 51820:51820/udp + restart: unless-stopped + networks: [netbird] + depends_on: + - netbird-server + env_file: + - ./proxy.env + volumes: + - netbird_proxy_certs:/certs + labels: + # TCP passthrough for any unmatched domain (proxy handles its own TLS) + - traefik.enable=true + - traefik.tcp.routers.proxy-passthrough.entrypoints=websecure + - traefik.tcp.routers.proxy-passthrough.rule=HostSNI(\`*\`) + - traefik.tcp.routers.proxy-passthrough.tls.passthrough=true + - traefik.tcp.routers.proxy-passthrough.service=proxy-tls + - traefik.tcp.routers.proxy-passthrough.priority=1 + - traefik.tcp.services.proxy-tls.loadbalancer.server.port=8443 + - traefik.tcp.services.proxy-tls.loadbalancer.serverstransport=pp-v2@file + logging: + driver: \"json-file\" + options: + max-size: \"500m\" + max-file: \"2\" +" + proxy_volumes=" + netbird_proxy_certs:" + fi + cat < ${upstream_host}:${RELAY_HOST_PORT}" - echo " (HTTP with WebSocket upgrade)" + echo " WebSocket (relay, signal, management WS proxy):" + echo " /relay*, /ws-proxy/* -> ${upstream_host}:${MANAGEMENT_HOST_PORT}" + echo " (HTTP with WebSocket upgrade, extended timeout)" echo "" - echo " /ws-proxy/signal* -> ${upstream_host}:${SIGNAL_HOST_PORT}" - echo " (HTTP with WebSocket upgrade)" - echo "" - echo " /signalexchange.SignalExchange/* -> ${upstream_host}:${SIGNAL_GRPC_PORT}" + echo " Native gRPC (signal + management):" + echo " /signalexchange.SignalExchange/* -> ${upstream_host}:${MANAGEMENT_HOST_PORT}" + echo " /management.ManagementService/* -> ${upstream_host}:${MANAGEMENT_HOST_PORT}" echo " (gRPC/h2c - plaintext HTTP/2)" echo "" - echo " /api/* -> ${upstream_host}:${MANAGEMENT_HOST_PORT}" - echo " (HTTP)" + echo " HTTP (API + embedded IdP):" + echo " /api/*, /oauth2/* -> ${upstream_host}:${MANAGEMENT_HOST_PORT}" echo "" - echo " /ws-proxy/management* -> ${upstream_host}:${MANAGEMENT_HOST_PORT}" - echo " (HTTP with WebSocket upgrade)" - echo "" - echo " /management.ManagementService/* -> ${upstream_host}:${MANAGEMENT_HOST_PORT}" - echo " (gRPC/h2c - plaintext HTTP/2)" - echo "" - echo " /oauth2/* -> ${upstream_host}:${MANAGEMENT_HOST_PORT}" - echo " (HTTP - embedded IdP)" - echo "" - echo " /* -> ${upstream_host}:${DASHBOARD_HOST_PORT}" - echo " (HTTP - catch-all for dashboard)" + echo " Dashboard (catch-all):" + echo " /* -> ${upstream_host}:${DASHBOARD_HOST_PORT}" echo "" echo "IMPORTANT: gRPC routes require HTTP/2 (h2c) upstream support." - echo "Long-running connections need extended timeouts (recommend 1 day)." + echo "WebSocket and gRPC connections need extended timeouts (recommend 1 day)." return 0 } print_post_setup_instructions() { case "$REVERSE_PROXY_TYPE" in 0) - print_caddy_instructions + print_builtin_traefik_instructions ;; 1) print_traefik_instructions diff --git a/infrastructure_files/migrate.sh b/infrastructure_files/migrate.sh new file mode 100755 index 000000000..67895fab6 --- /dev/null +++ b/infrastructure_files/migrate.sh @@ -0,0 +1,1286 @@ +#!/bin/bash +# +# NetBird Migration Script: Pre-v0.65.0 → Combined Container Setup +# +# Migrates from the old 5-container deployment (dashboard, signal, relay, management, coturn) +# to the new 2-container setup (Traefik + combined netbird-server). +# +# Supported: Embedded IdP (Dex) setups with embedded Caddy or custom reverse proxy. +# Not supported: External IdP (Auth0, Keycloak, etc.) — use getting-started.sh for fresh setup. +# +# Usage: +# ./migrate.sh [--install-dir /path/to/netbird] [--non-interactive] + +set -euo pipefail + +############################################ +# Constants +############################################ + +readonly SCRIPT_VERSION="1.0.0" +readonly DASHBOARD_IMAGE="netbirdio/dashboard:latest" +readonly NETBIRD_SERVER_IMAGE="netbirdio/netbird-server:latest" +readonly SED_STRIP_PADDING='s/=//g' +readonly MSG_SEPARATOR="==========================================" +readonly PROXY_TYPE_CADDY="caddy_embedded" + +# Colors (disabled if not a terminal) +if [[ -t 1 ]]; then + readonly RED='\033[0;31m' + readonly GREEN='\033[0;32m' + readonly YELLOW='\033[1;33m' + readonly BLUE='\033[0;34m' + readonly NC='\033[0m' +else + readonly RED='' + readonly GREEN='' + readonly YELLOW='' + readonly BLUE='' + readonly NC='' +fi + +############################################ +# Global Variables (set during detection) +############################################ + +INSTALL_DIR="" +NON_INTERACTIVE=false +DOCKER_COMPOSE_CMD="" + +# Detection results +PROXY_TYPE="" # caddy_embedded | traefik | external +IDP_TYPE="" # embedded | external +MGMT_VOLUME="" # detected management volume name +DOMAIN="" +LETSENCRYPT_EMAIL="" +STORE_ENGINE="sqlite" +STORE_DSN="" +ENCRYPTION_KEY="" +RELAY_SECRET="" +SIGNKEY_REFRESH="true" +TRUSTED_PROXIES="" +TRUSTED_PROXIES_COUNT="" +TRUSTED_PEERS="" +MANAGEMENT_JSON_PATH="" +BACKUP_DIR="" + +############################################ +# Utility Functions +############################################ + +log_info() { + local msg="$1" + echo -e "${BLUE}[INFO]${NC} ${msg}" + return 0 +} + +log_warn() { + local msg="$1" + echo -e "${YELLOW}[WARN]${NC} ${msg}" + return 0 +} + +log_error() { + local msg="$1" + echo -e "${RED}[ERROR]${NC} ${msg}" >&2 + return 0 +} + +log_success() { + local msg="$1" + echo -e "${GREEN}[OK]${NC} ${msg}" + return 0 +} + +print_banner() { + echo "" + echo "$MSG_SEPARATOR" + echo " NetBird Migration Tool v${SCRIPT_VERSION}" + echo " Pre-v0.65.0 → Combined Container Setup" + echo "$MSG_SEPARATOR" + echo "" + return 0 +} + +confirm_action() { + local prompt="$1" + if [[ "$NON_INTERACTIVE" == "true" ]]; then + return 0 + fi + echo "" + echo -n "$prompt [y/N]: " + read -r response < /dev/tty + if [[ ! "$response" =~ ^[Yy]$ ]]; then + log_error "Aborted by user." + exit 1 + fi + return 0 +} + +############################################ +# Phase 0: Preflight & Detection +############################################ + +check_dependencies() { + log_info "Checking dependencies..." + + local missing=() + + if ! command -v docker &>/dev/null; then + missing+=("docker") + fi + + if command -v docker-compose &>/dev/null; then + DOCKER_COMPOSE_CMD="docker-compose" + elif docker compose --help &>/dev/null 2>&1; then + DOCKER_COMPOSE_CMD="docker compose" + else + missing+=("docker-compose") + fi + + if ! command -v jq &>/dev/null; then + missing+=("jq") + fi + + if ! command -v openssl &>/dev/null; then + missing+=("openssl") + fi + + if ! command -v curl &>/dev/null; then + missing+=("curl") + fi + + if [[ ${#missing[@]} -gt 0 ]]; then + log_error "Missing required dependencies: ${missing[*]}" + echo "Please install them and re-run the script." + exit 1 + fi + + log_success "All dependencies found (docker compose: '$DOCKER_COMPOSE_CMD')" + return 0 +} + +detect_install_dir() { + if [[ -n "$INSTALL_DIR" ]]; then + if [[ ! -d "$INSTALL_DIR" ]]; then + log_error "Specified install directory does not exist: $INSTALL_DIR" + exit 1 + fi + return 0 + fi + + log_info "Detecting installation directory..." + + local search_paths=("$PWD" "/opt/netbird" "/opt/wiretrustee") + for dir in "${search_paths[@]}"; do + if [[ -f "$dir/management.json" ]] || [[ -f "$dir/artifacts/management.json" ]]; then + INSTALL_DIR="$dir" + log_success "Found installation at: $INSTALL_DIR" + return 0 + fi + done + + if [[ "$NON_INTERACTIVE" == "true" ]]; then + log_error "Could not auto-detect installation directory. Use --install-dir to specify." + exit 1 + fi + + echo "" + echo -n "Enter the path to your NetBird installation directory: " + read -r INSTALL_DIR < /dev/tty + if [[ ! -d "$INSTALL_DIR" ]]; then + log_error "Directory does not exist: $INSTALL_DIR" + exit 1 + fi + return 0 +} + +validate_old_setup() { + log_info "Validating old setup..." + + # Find management.json — check both root and artifacts/ + if [[ -f "$INSTALL_DIR/management.json" ]]; then + MANAGEMENT_JSON_PATH="$INSTALL_DIR/management.json" + elif [[ -f "$INSTALL_DIR/artifacts/management.json" ]]; then + MANAGEMENT_JSON_PATH="$INSTALL_DIR/artifacts/management.json" + else + log_error "Cannot find management.json in $INSTALL_DIR or $INSTALL_DIR/artifacts/" + echo "This doesn't appear to be a valid NetBird installation." + exit 1 + fi + + # Check for docker-compose.yml (in root or artifacts/) + local compose_found=false + if [[ -f "$INSTALL_DIR/docker-compose.yml" ]]; then + compose_found=true + elif [[ -f "$INSTALL_DIR/artifacts/docker-compose.yml" ]]; then + compose_found=true + fi + + if [[ "$compose_found" != "true" ]]; then + log_error "Cannot find docker-compose.yml in $INSTALL_DIR or $INSTALL_DIR/artifacts/" + exit 1 + fi + + log_success "Found management.json at: $MANAGEMENT_JSON_PATH" + return 0 +} + +check_already_migrated() { + if [[ -f "$INSTALL_DIR/config.yaml" ]]; then + log_warn "config.yaml already exists in $INSTALL_DIR" + echo "It appears this installation has already been migrated." + echo "If you want to re-run the migration, remove config.yaml first." + exit 0 + fi + return 0 +} + +detect_reverse_proxy() { + log_info "Detecting reverse proxy type..." + + local compose_file="" + if [[ -f "$INSTALL_DIR/docker-compose.yml" ]]; then + compose_file="$INSTALL_DIR/docker-compose.yml" + elif [[ -f "$INSTALL_DIR/artifacts/docker-compose.yml" ]]; then + compose_file="$INSTALL_DIR/artifacts/docker-compose.yml" + fi + + # Check for Traefik service or labels + if grep -q 'traefik' "$compose_file" 2>/dev/null; then + PROXY_TYPE="traefik" + log_info "Detected: Traefik reverse proxy" + return 0 + fi + + # Check for embedded Caddy — two patterns: + # 1. Old configure.sh: dashboard container with LETSENCRYPT_DOMAIN env var + ports 80/443 + # 2. v0.62+ getting-started.sh: Caddy service in compose or standalone Caddyfile + if grep -q 'LETSENCRYPT_DOMAIN' "$compose_file" 2>/dev/null && { grep -q '443:443' "$compose_file" 2>/dev/null || grep -q '443:' "$compose_file" 2>/dev/null; }; then + PROXY_TYPE="$PROXY_TYPE_CADDY" + log_info "Detected: Embedded Caddy (dashboard container with Let's Encrypt)" + return 0 + fi + + # Check for Caddy service in docker-compose.yml (v0.62+ pattern) + if grep -qE '^\s+caddy:|^\s+image:.*caddy' "$compose_file" 2>/dev/null; then + PROXY_TYPE="$PROXY_TYPE_CADDY" + log_info "Detected: Caddy reverse proxy (in Docker Compose)" + return 0 + fi + + # Check for standalone Caddyfile in install directory (v0.62+ getting-started.sh) + if [[ -f "$INSTALL_DIR/Caddyfile" ]]; then + # Verify Caddy is referenced in docker-compose.yml or running as a container + if grep -q 'caddy' "$compose_file" 2>/dev/null || grep -q 'Caddyfile' "$compose_file" 2>/dev/null; then + PROXY_TYPE="$PROXY_TYPE_CADDY" + log_info "Detected: Caddy reverse proxy (Caddyfile + Docker Compose)" + return 0 + fi + # Caddyfile exists but not in compose — might be running on host + PROXY_TYPE="$PROXY_TYPE_CADDY" + log_info "Detected: Caddy reverse proxy (standalone Caddyfile)" + return 0 + fi + + # Check for disabled Let's Encrypt (external proxy) + if [[ -f "$INSTALL_DIR/setup.env" ]] && grep -q 'NETBIRD_DISABLE_LETSENCRYPT=true' "$INSTALL_DIR/setup.env" 2>/dev/null; then + PROXY_TYPE="external" + log_info "Detected: External reverse proxy (Let's Encrypt disabled)" + return 0 + fi + + # Default to external + PROXY_TYPE="external" + log_info "Detected: External/custom reverse proxy" + return 0 +} + +detect_idp_type() { + log_info "Detecting identity provider type..." + + # Check for embedded IdP (v0.62.0+ getting-started.sh format) + local embedded_enabled + embedded_enabled=$(jq -r '.EmbeddedIdP.Enabled // false' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "false") + if [[ "$embedded_enabled" == "true" ]]; then + IDP_TYPE="embedded" + log_success "IdP type: embedded (suitable for migration)" + return 0 + fi + + # Check IdpManagerConfig.ManagerType (old configure.sh format) + local manager_type + manager_type=$(jq -r '.IdpManagerConfig.ManagerType // ""' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "") + + if [[ -n "$manager_type" && "$manager_type" != "null" && "$manager_type" != "none" && "$manager_type" != "" ]]; then + IDP_TYPE="external" + log_error "External IdP detected: $manager_type" + echo "" + echo "This migration script only supports embedded IdP setups." + echo "External IdP providers (Auth0, Keycloak, Zitadel, etc.) require" + echo "a fresh installation using getting-started.sh." + echo "" + echo "Please refer to the NetBird documentation for upgrade instructions:" + echo " https://docs.netbird.io/selfhosted/getting-started" + exit 1 + fi + + # Check HttpConfig.AuthIssuer for well-known external providers + local auth_issuer + auth_issuer=$(jq -r '.HttpConfig.AuthIssuer // ""' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "") + + if [[ -n "$auth_issuer" && "$auth_issuer" != "null" ]]; then + for provider in "auth0.com" "accounts.google.com" "login.microsoftonline.com" "keycloak" "zitadel" "authentik"; do + if echo "$auth_issuer" | grep -qi "$provider" 2>/dev/null; then + log_error "External OIDC provider detected: $auth_issuer" + echo "" + echo "This migration script only supports embedded IdP setups." + echo "Please use getting-started.sh for a fresh installation." + exit 1 + fi + done + fi + + # No embedded IdP and no external IdP detected — assume old setup without IdP manager + IDP_TYPE="embedded" + log_success "IdP type: embedded (suitable for migration)" + return 0 +} + +detect_volumes() { + log_info "Detecting Docker volumes..." + + local volumes_list + volumes_list=$(docker volume ls --format '{{.Name}}' 2>/dev/null || echo "") + + # Check for well-known volume name patterns (exact match) + local volume_patterns=( + "wiretrustee-mgmt" + "netbird-mgmt" + ) + for pattern in "${volume_patterns[@]}"; do + if echo "$volumes_list" | grep -q "^${pattern}$"; then + MGMT_VOLUME="$pattern" + log_success "Found management volume: $MGMT_VOLUME" + return 0 + fi + done + + # Check compose-prefixed patterns (e.g., netbird_netbird-mgmt, infrastructure_files_netbird-mgmt) + local compose_prefixed + compose_prefixed=$(echo "$volumes_list" | grep -E '(netbird|wiretrustee).*mgmt' | head -n1 || echo "") + if [[ -n "$compose_prefixed" ]]; then + MGMT_VOLUME="$compose_prefixed" + log_success "Found management volume (compose-prefixed): $MGMT_VOLUME" + return 0 + fi + + # Try to extract volume name from old docker-compose.yml + local compose_file="" + if [[ -f "$INSTALL_DIR/docker-compose.yml" ]]; then + compose_file="$INSTALL_DIR/docker-compose.yml" + elif [[ -f "$INSTALL_DIR/artifacts/docker-compose.yml" ]]; then + compose_file="$INSTALL_DIR/artifacts/docker-compose.yml" + fi + if [[ -n "$compose_file" ]]; then + # Look for volume mount on /var/lib/netbird in management or netbird-server service + local vol_name + vol_name=$(grep -E '^\s+-\s+\S+:/var/lib/netbird' "$compose_file" 2>/dev/null | head -1 | sed 's/.*- //' | sed 's/:.*//' | tr -d ' ' || echo "") + if [[ -n "$vol_name" && "$vol_name" != "." && "$vol_name" != "/" ]]; then + # Check if this volume exists in Docker + local full_vol + full_vol=$(echo "$volumes_list" | grep -F "$vol_name" | head -1 || echo "") + if [[ -n "$full_vol" ]]; then + MGMT_VOLUME="$full_vol" + log_success "Found management volume (from compose): $MGMT_VOLUME" + return 0 + fi + fi + fi + + log_warn "Could not detect management volume. A new volume will be created." + MGMT_VOLUME="" + return 0 +} + +detect_domain() { + log_info "Detecting domain..." + + # Try setup.env first + if [[ -z "$DOMAIN" && -f "$INSTALL_DIR/setup.env" ]]; then + DOMAIN=$(grep '^NETBIRD_DOMAIN=' "$INSTALL_DIR/setup.env" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "") + fi + + # Try EmbeddedIdP.Issuer (v0.62.0+ getting-started.sh format) + if [[ -z "$DOMAIN" ]]; then + local issuer + issuer=$(jq -r '.EmbeddedIdP.Issuer // ""' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "") + if [[ -n "$issuer" && "$issuer" != "null" ]]; then + DOMAIN=$(echo "$issuer" | sed 's|https\?://||' | sed 's|/.*||' | sed 's|:.*||') + fi + fi + + # Try HttpConfig.AuthIssuer (old configure.sh format) + if [[ -z "$DOMAIN" ]]; then + local issuer + issuer=$(jq -r '.HttpConfig.AuthIssuer // ""' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "") + if [[ -n "$issuer" && "$issuer" != "null" ]]; then + DOMAIN=$(echo "$issuer" | sed 's|https\?://||' | sed 's|/.*||' | sed 's|:.*||') + fi + fi + + # Try dashboard.env NETBIRD_MGMT_API_ENDPOINT + if [[ -z "$DOMAIN" && -f "$INSTALL_DIR/dashboard.env" ]]; then + local endpoint + endpoint=$(grep '^NETBIRD_MGMT_API_ENDPOINT=' "$INSTALL_DIR/dashboard.env" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "") + if [[ -n "$endpoint" ]]; then + DOMAIN=$(echo "$endpoint" | sed 's|https\?://||' | sed 's|/.*||' | sed 's|:.*||') + fi + fi + + if [[ -z "$DOMAIN" ]]; then + log_error "Could not detect domain from management.json, setup.env, or dashboard.env." + exit 1 + fi + + # Detect Let's Encrypt email from setup.env or dashboard.env LETSENCRYPT_DOMAIN + if [[ -f "$INSTALL_DIR/setup.env" ]]; then + LETSENCRYPT_EMAIL=$(grep '^NETBIRD_LETSENCRYPT_EMAIL=' "$INSTALL_DIR/setup.env" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "") + fi + + log_success "Domain: $DOMAIN" + if [[ -n "$LETSENCRYPT_EMAIL" ]]; then + log_success "Let's Encrypt email: $LETSENCRYPT_EMAIL" + fi + return 0 +} + +detect_store_config() { + log_info "Detecting store configuration..." + + # Engine from management.json + local engine + engine=$(jq -r '.StoreConfig.Engine // ""' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "") + if [[ -n "$engine" && "$engine" != "null" && "$engine" != "" ]]; then + STORE_ENGINE="$engine" + fi + + # DSN from environment files + if [[ -f "$INSTALL_DIR/setup.env" ]]; then + local pg_dsn + pg_dsn=$(grep '^NETBIRD_STORE_ENGINE_POSTGRES_DSN=' "$INSTALL_DIR/setup.env" 2>/dev/null | sed 's/^NETBIRD_STORE_ENGINE_POSTGRES_DSN=//' | tr -d '"' || echo "") + if [[ -n "$pg_dsn" ]]; then + STORE_DSN="$pg_dsn" + fi + + local mysql_dsn + mysql_dsn=$(grep '^NETBIRD_STORE_ENGINE_MYSQL_DSN=' "$INSTALL_DIR/setup.env" 2>/dev/null | sed 's/^NETBIRD_STORE_ENGINE_MYSQL_DSN=//' | tr -d '"' || echo "") + if [[ -n "$mysql_dsn" ]]; then + STORE_DSN="$mysql_dsn" + fi + fi + + # Also check base.setup.env + if [[ -z "$STORE_DSN" && -f "$INSTALL_DIR/base.setup.env" ]]; then + local pg_dsn + pg_dsn=$(grep '^NETBIRD_STORE_ENGINE_POSTGRES_DSN=' "$INSTALL_DIR/base.setup.env" 2>/dev/null | sed 's/^NETBIRD_STORE_ENGINE_POSTGRES_DSN=//' | tr -d '"' || echo "") + if [[ -n "$pg_dsn" ]]; then + STORE_DSN="$pg_dsn" + fi + + local mysql_dsn + mysql_dsn=$(grep '^NETBIRD_STORE_ENGINE_MYSQL_DSN=' "$INSTALL_DIR/base.setup.env" 2>/dev/null | sed 's/^NETBIRD_STORE_ENGINE_MYSQL_DSN=//' | tr -d '"' || echo "") + if [[ -n "$mysql_dsn" ]]; then + STORE_DSN="$mysql_dsn" + fi + fi + + log_success "Store engine: $STORE_ENGINE" + if [[ -n "$STORE_DSN" ]]; then + log_success "Store DSN: [detected]" + fi + return 0 +} + +extract_config_values() { + log_info "Extracting configuration from management.json..." + + # DataStoreEncryptionKey + ENCRYPTION_KEY=$(jq -r '.DataStoreEncryptionKey // ""' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "") + if [[ -z "$ENCRYPTION_KEY" || "$ENCRYPTION_KEY" == "null" ]]; then + ENCRYPTION_KEY=$(openssl rand -base64 32) + log_warn "No encryption key found in management.json — generated a new one." + log_warn "IMPORTANT: Save this key! Without it, existing encrypted data cannot be read." + echo " Encryption key: $ENCRYPTION_KEY" + fi + + # Relay secret from management.json + RELAY_SECRET=$(jq -r '.Relay.Secret // ""' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "") + + # Fallback: relay secret from setup.env + if [[ (-z "$RELAY_SECRET" || "$RELAY_SECRET" == "null") && -f "$INSTALL_DIR/setup.env" ]]; then + RELAY_SECRET=$(grep '^NETBIRD_RELAY_AUTH_SECRET=' "$INSTALL_DIR/setup.env" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "") + fi + + # Fallback: relay secret from base.setup.env + if [[ (-z "$RELAY_SECRET" || "$RELAY_SECRET" == "null") && -f "$INSTALL_DIR/base.setup.env" ]]; then + RELAY_SECRET=$(grep '^NETBIRD_RELAY_AUTH_SECRET=' "$INSTALL_DIR/base.setup.env" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "") + fi + + # Generate if still empty + if [[ -z "$RELAY_SECRET" || "$RELAY_SECRET" == "null" ]]; then + RELAY_SECRET=$(openssl rand -base64 32 | sed "$SED_STRIP_PADDING") + log_warn "No relay secret found — generated a new one." + fi + + # IdpSignKeyRefreshEnabled — check both HttpConfig and EmbeddedIdP locations + local signkey_raw + signkey_raw=$(jq -r '(.HttpConfig.IdpSignKeyRefreshEnabled // .EmbeddedIdP.SignKeyRefreshEnabled) // "true"' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "true") + if [[ "$signkey_raw" == "false" ]]; then + SIGNKEY_REFRESH="false" + else + SIGNKEY_REFRESH="true" + fi + + # ReverseProxy settings (may not exist in v0.62+ getting-started.sh format) + TRUSTED_PROXIES=$(jq -c '.ReverseProxy.TrustedHTTPProxies // []' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "[]") + TRUSTED_PROXIES_COUNT=$(jq -r '.ReverseProxy.TrustedHTTPProxiesCount // 0' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "0") + TRUSTED_PEERS=$(jq -c '.ReverseProxy.TrustedPeers // []' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "[]") + + log_success "Configuration values extracted" + return 0 +} + +print_detection_summary() { + echo "" + echo "$MSG_SEPARATOR" + echo " Migration Summary" + echo "$MSG_SEPARATOR" + echo "" + echo " Install directory: $INSTALL_DIR" + echo " Domain: $DOMAIN" + echo " Reverse proxy: $PROXY_TYPE" + echo " Store engine: $STORE_ENGINE" + if [[ -n "$STORE_DSN" ]]; then + echo " Store DSN: [configured]" + fi + if [[ -n "$MGMT_VOLUME" ]]; then + echo " Management volume: $MGMT_VOLUME" + else + echo " Management volume: [new volume will be created]" + fi + echo " Encryption key: ${ENCRYPTION_KEY:0:8}..." + echo " Relay secret: ${RELAY_SECRET:0:8}..." + echo "" + + if [[ "$PROXY_TYPE" == "$PROXY_TYPE_CADDY" ]]; then + echo " Migration mode: AUTOMATIC" + echo " A Traefik-based docker-compose.yml will be generated and services" + echo " will be stopped and restarted automatically." + else + echo " Migration mode: MANUAL" + echo " New config files will be generated. You will need to stop old" + echo " containers, replace docker-compose.yml, and restart manually." + fi + echo "" + return 0 +} + +############################################ +# Phase 1: Backup +############################################ + +create_backup() { + BACKUP_DIR="$INSTALL_DIR/backup-$(date +%Y%m%d-%H%M%S)" + log_info "Creating backup at: $BACKUP_DIR" + mkdir -p "$BACKUP_DIR" + + # Copy config files + local files_to_backup=( + "docker-compose.yml" + "management.json" + "setup.env" + "base.setup.env" + "turnserver.conf" + "dashboard.env" + ) + + for f in "${files_to_backup[@]}"; do + if [[ -f "$INSTALL_DIR/$f" ]]; then + cp "$INSTALL_DIR/$f" "$BACKUP_DIR/$f" + fi + done + + # Back up artifacts/ if it exists + if [[ -d "$INSTALL_DIR/artifacts" ]]; then + cp -r "$INSTALL_DIR/artifacts" "$BACKUP_DIR/artifacts" + fi + + # Record state + { + echo "# NetBird migration backup state" + echo "# Created: $(date -u '+%Y-%m-%d %H:%M:%S UTC')" + echo "" + echo "## Docker volumes" + docker volume ls --format '{{.Name}}' 2>/dev/null | grep -E '(netbird|wiretrustee)' || echo "(none found)" + echo "" + echo "## Running containers" + docker ps --format '{{.Names}}\t{{.Image}}\t{{.Status}}' 2>/dev/null | grep -E '(netbird|wiretrustee|dashboard|signal|relay|management|coturn)' || echo "(none running)" + } > "$BACKUP_DIR/state.txt" + + # Generate rollback script + generate_rollback_script + + log_success "Backup created at: $BACKUP_DIR" + return 0 +} + +generate_rollback_script() { + cat > "$BACKUP_DIR/rollback.sh" <<'ROLLBACK_HEADER' +#!/bin/bash +set -euo pipefail + +# NetBird Migration Rollback Script +# Restores the pre-migration configuration and restarts old containers. + +ROLLBACK_HEADER + + cat >> "$BACKUP_DIR/rollback.sh" </dev/null; then + COMPOSE_CMD="docker-compose" +elif docker compose --help &>/dev/null 2>&1; then + COMPOSE_CMD="docker compose" +else + echo "ERROR: docker compose not found" >&2 + exit 1 +fi + +echo "Stopping current containers..." +\$COMPOSE_CMD down 2>/dev/null || true + +# Restore old config files +echo "Restoring configuration files..." +for f in docker-compose.yml management.json setup.env base.setup.env turnserver.conf dashboard.env; do + if [[ -f "\$BACKUP_DIR/\$f" ]]; then + cp "\$BACKUP_DIR/\$f" "\$INSTALL_DIR/\$f" + echo " Restored: \$f" + fi +done + +# Remove new config files +for f in config.yaml; do + if [[ -f "\$INSTALL_DIR/\$f" ]]; then + rm "\$INSTALL_DIR/\$f" + echo " Removed: \$f" + fi +done + +# Restart old containers +echo "Starting old containers..." +cd "\$INSTALL_DIR" +\$COMPOSE_CMD up -d + +echo "" +echo "Rollback complete. Old containers are running." +echo "Verify with: \$COMPOSE_CMD ps" +ROLLBACK_BODY + + chmod +x "$BACKUP_DIR/rollback.sh" + return 0 +} + +############################################ +# Phase 3: Generate New Configuration Files +############################################ + +generate_config_yaml() { + log_info "Generating config.yaml..." + + local dsn_line="" + if [[ -n "$STORE_DSN" ]]; then + dsn_line=" dsn: \"$STORE_DSN\"" + fi + + local reverse_proxy_section="" + # Only add reverseProxy if there are non-default values + local has_proxy_config=false + if [[ "$TRUSTED_PROXIES" != "[]" && -n "$TRUSTED_PROXIES" ]]; then + has_proxy_config=true + fi + if [[ "$TRUSTED_PROXIES_COUNT" != "0" && -n "$TRUSTED_PROXIES_COUNT" ]]; then + has_proxy_config=true + fi + if [[ "$TRUSTED_PEERS" != "[]" && -n "$TRUSTED_PEERS" ]]; then + # Check if it's only the default ["0.0.0.0/0"] + local default_peers='["0.0.0.0/0"]' + if [[ "$TRUSTED_PEERS" != "$default_peers" ]]; then + has_proxy_config=true + fi + fi + + if [[ "$has_proxy_config" == "true" ]]; then + reverse_proxy_section=" + reverseProxy:" + if [[ "$TRUSTED_PROXIES" != "[]" && -n "$TRUSTED_PROXIES" ]]; then + reverse_proxy_section+=" + trustedHTTPProxies:" + for proxy in $(echo "$TRUSTED_PROXIES" | jq -r '.[]' 2>/dev/null); do + reverse_proxy_section+=" + - \"$proxy\"" + done + fi + if [[ "$TRUSTED_PROXIES_COUNT" != "0" && -n "$TRUSTED_PROXIES_COUNT" ]]; then + reverse_proxy_section+=" + trustedHTTPProxiesCount: $TRUSTED_PROXIES_COUNT" + fi + if [[ "$TRUSTED_PEERS" != "[]" && -n "$TRUSTED_PEERS" ]]; then + reverse_proxy_section+=" + trustedPeers:" + for peer in $(echo "$TRUSTED_PEERS" | jq -r '.[]' 2>/dev/null); do + reverse_proxy_section+=" + - \"$peer\"" + done + fi + fi + + { + cat < "$INSTALL_DIR/config.yaml" + + log_success "Generated config.yaml" + return 0 +} + +generate_dashboard_env() { + log_info "Generating dashboard.env..." + + cat > "$INSTALL_DIR/dashboard.env" < "$INSTALL_DIR/docker-compose.yml" < "$INSTALL_DIR/docker-compose.yml" </dev/null) || true + + log_success "Old containers stopped" + return 0 +} + +start_new_services() { + log_info "Starting new containers..." + + (cd "$INSTALL_DIR" && $DOCKER_COMPOSE_CMD up -d) + + log_success "New containers started" + return 0 +} + +wait_for_health() { + log_info "Waiting for services to become healthy..." + + local max_attempts=60 + local attempt=0 + + set +e + echo -n " Checking" + while [[ $attempt -lt $max_attempts ]]; do + # Try OIDC endpoint through reverse proxy + if curl -sk -f -o /dev/null "https://${DOMAIN}/oauth2/.well-known/openid-configuration" 2>/dev/null; then + echo " done" + set -e + log_success "Services are healthy" + return 0 + fi + + # Also try health check endpoint directly + if curl -sk -f -o /dev/null "http://127.0.0.1:9000/" 2>/dev/null; then + echo " done" + set -e + log_success "Services are healthy (via healthcheck)" + return 0 + fi + + echo -n " ." + sleep 2 + attempt=$((attempt + 1)) + + if [[ $attempt -eq 30 ]]; then + echo "" + log_warn "Taking longer than expected. Checking container logs..." + (cd "$INSTALL_DIR" && $DOCKER_COMPOSE_CMD logs --tail=10 netbird-server 2>/dev/null) || true + echo -n " Still checking" + fi + done + echo "" + set -e + + log_warn "Health check timed out after $((max_attempts * 2)) seconds." + log_warn "Services may still be starting. Check with: cd $INSTALL_DIR && $DOCKER_COMPOSE_CMD logs" + return 0 +} + +############################################ +# Phase 5: Verification & Summary +############################################ + +verify_migration() { + log_info "Running verification checks..." + + local checks_passed=0 + local checks_total=3 + + # Check 1: Container health + local running + running=$(cd "$INSTALL_DIR" && $DOCKER_COMPOSE_CMD ps --format '{{.Name}}' 2>/dev/null | wc -l || echo "0") + if [[ "$running" -ge 2 ]]; then + log_success "Containers are running ($running services)" + checks_passed=$((checks_passed + 1)) + else + log_warn "Expected at least 2 running containers, found $running" + fi + + # Check 2: OIDC endpoint + local oidc_status + oidc_status=$(curl -sk -o /dev/null -w '%{http_code}' "https://${DOMAIN}/oauth2/.well-known/openid-configuration" 2>/dev/null || echo "000") + if [[ "$oidc_status" == "200" ]]; then + log_success "OIDC endpoint responding (HTTP $oidc_status)" + checks_passed=$((checks_passed + 1)) + else + log_warn "OIDC endpoint returned HTTP $oidc_status (expected 200)" + fi + + # Check 3: Management API (expect 401 = working but needs auth, not 502 = proxy error) + local api_status + api_status=$(curl -sk -o /dev/null -w '%{http_code}' "https://${DOMAIN}/api/accounts" 2>/dev/null || echo "000") + if [[ "$api_status" == "401" || "$api_status" == "200" || "$api_status" == "403" ]]; then + log_success "Management API responding (HTTP $api_status)" + checks_passed=$((checks_passed + 1)) + else + log_warn "Management API returned HTTP $api_status (expected 401/200/403)" + fi + + echo "" + echo " Verification: $checks_passed/$checks_total checks passed" + return 0 +} + +print_summary() { + echo "" + echo "$MSG_SEPARATOR" + echo " Migration Complete" + echo "$MSG_SEPARATOR" + echo "" + + if [[ "$PROXY_TYPE" == "$PROXY_TYPE_CADDY" ]]; then + echo " What was done:" + echo " - Old 5-container setup stopped" + echo " - New config.yaml generated (combined server config)" + echo " - New dashboard.env generated (embedded IdP)" + echo " - New docker-compose.yml generated (Traefik + combined server)" + echo " - New containers started" + else + echo " What was done:" + echo " - New config.yaml generated (combined server config)" + echo " - New dashboard.env generated (embedded IdP)" + echo " - New docker-compose.yml generated (exposed ports)" + echo "" + echo " What you need to do:" + echo " 1. Stop old containers:" + echo " cd $INSTALL_DIR && $DOCKER_COMPOSE_CMD down" + echo "" + echo " 2. Start new containers:" + echo " cd $INSTALL_DIR && $DOCKER_COMPOSE_CMD up -d" + echo "" + echo " 3. Update your reverse proxy to route:" + echo " - /signalexchange.SignalExchange/* -> 127.0.0.1:8081 (gRPC/h2c)" + echo " - /management.ManagementService/* -> 127.0.0.1:8081 (gRPC/h2c)" + echo " - /relay*, /ws-proxy/* -> 127.0.0.1:8081 (WebSocket)" + echo " - /api/*, /oauth2/* -> 127.0.0.1:8081 (HTTP)" + echo " - /* -> 127.0.0.1:8080 (dashboard)" + fi + + echo "" + echo " Backup location: $BACKUP_DIR" + echo " Rollback command: bash $BACKUP_DIR/rollback.sh" + echo "" + echo " IMPORTANT:" + echo " - Existing peers, routes, and policies are preserved in the database." + echo " - The embedded IdP data is preserved in the management volume." + echo " - Clients should reconnect automatically; if not: netbird down && netbird up" + echo "" + echo " Next steps:" + echo " - Access the dashboard: https://$DOMAIN" + echo " - Re-authenticate all clients: netbird down && netbird up" + echo " - Check logs: cd $INSTALL_DIR && $DOCKER_COMPOSE_CMD logs -f" + echo "" + return 0 +} + +############################################ +# Main +############################################ + +main() { + # Parse arguments + while [[ $# -gt 0 ]]; do + local arg="$1" + case "$arg" in + --install-dir) + local dir_value="$2" + INSTALL_DIR="$dir_value" + shift 2 + ;; + --non-interactive) + NON_INTERACTIVE=true + shift + ;; + --help|-h) + echo "Usage: $0 [--install-dir /path/to/netbird] [--non-interactive]" + echo "" + echo "Migrates a pre-v0.65.0 NetBird deployment to the combined container setup." + echo "" + echo "Options:" + echo " --install-dir DIR Path to existing NetBird installation" + echo " --non-interactive Skip confirmation prompts (for automation)" + echo " -h, --help Show this help message" + exit 0 + ;; + *) + log_error "Unknown option: $arg" + echo "Use --help for usage information." + exit 1 + ;; + esac + done + + print_banner + + # Phase 0: Preflight & Detection + check_dependencies + detect_install_dir + validate_old_setup + check_already_migrated + detect_reverse_proxy + detect_idp_type + detect_volumes + detect_domain + detect_store_config + extract_config_values + print_detection_summary + + confirm_action "Proceed with migration?" + + # Phase 1: Backup + create_backup + + # Phase 4: Apply migration + if [[ "$PROXY_TYPE" == "$PROXY_TYPE_CADDY" ]]; then + # Stop old containers BEFORE overwriting docker-compose.yml + stop_old_services + + # Phase 2 + 3: Generate new configuration files + generate_config_yaml + generate_dashboard_env + generate_docker_compose + + start_new_services + sleep 3 + wait_for_health + + # Phase 5: Verification + verify_migration + else + # For manual proxy setups, just generate files (don't stop/start) + generate_config_yaml + generate_dashboard_env + generate_docker_compose + fi + + print_summary + return 0 +} + +main "$@" diff --git a/management/Dockerfile.multistage b/management/Dockerfile.multistage new file mode 100644 index 000000000..619f84615 --- /dev/null +++ b/management/Dockerfile.multistage @@ -0,0 +1,17 @@ +FROM golang:1.25-bookworm AS builder +WORKDIR /app + +# Install build dependencies +RUN apt-get update && apt-get install -y gcc libc6-dev && rm -rf /var/lib/apt/lists/* + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o netbird-mgmt ./management + +FROM ubuntu:24.04 +RUN apt update && apt install -y ca-certificates && rm -fr /var/cache/apt +ENTRYPOINT [ "/go/bin/netbird-mgmt","management"] +CMD ["--log-file", "console"] +COPY --from=builder /app/netbird-mgmt /go/bin/netbird-mgmt diff --git a/management/cmd/management.go b/management/cmd/management.go index 7da04074b..27d8055e7 100644 --- a/management/cmd/management.go +++ b/management/cmd/management.go @@ -16,22 +16,24 @@ import ( "strings" "syscall" - "github.com/miekg/dns" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/formatter/hook" "github.com/netbirdio/netbird/management/internals/server" nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + nbdomain "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/util" "github.com/netbirdio/netbird/util/crypt" ) -var newServer = func(config *nbconfig.Config, dnsDomain, mgmtSingleAccModeDomain string, mgmtPort int, mgmtMetricsPort int, disableMetrics, disableGeoliteUpdate, userDeleteFromIDPEnabled bool) server.Server { - return server.NewServer(config, dnsDomain, mgmtSingleAccModeDomain, mgmtPort, mgmtMetricsPort, disableMetrics, disableGeoliteUpdate, userDeleteFromIDPEnabled) +var newServer = func(cfg *server.Config) server.Server { + return server.NewServer(cfg) } -func SetNewServer(fn func(config *nbconfig.Config, dnsDomain, mgmtSingleAccModeDomain string, mgmtPort int, mgmtMetricsPort int, disableMetrics, disableGeoliteUpdate, userDeleteFromIDPEnabled bool) server.Server) { +func SetNewServer(fn func(*server.Config) server.Server) { newServer = fn } @@ -55,7 +57,7 @@ var ( // detect whether user specified a port userPort := cmd.Flag("port").Changed - config, err = loadMgmtConfig(ctx, nbconfig.MgmtConfigPath) + config, err = LoadMgmtConfig(ctx, nbconfig.MgmtConfigPath) if err != nil { return fmt.Errorf("failed reading provided config file: %s: %v", nbconfig.MgmtConfigPath, err) } @@ -78,9 +80,8 @@ var ( } } - _, valid := dns.IsDomainName(dnsDomain) - if !valid || len(dnsDomain) > 192 { - return fmt.Errorf("failed parsing the provided dns-domain. Valid status: %t, Length: %d", valid, len(dnsDomain)) + if !nbdomain.IsValidDomainNoWildcard(dnsDomain) { + return fmt.Errorf("invalid dns-domain: %s", dnsDomain) } return nil @@ -109,7 +110,17 @@ var ( mgmtSingleAccModeDomain = "" } - srv := newServer(config, dnsDomain, mgmtSingleAccModeDomain, mgmtPort, mgmtMetricsPort, disableMetrics, disableGeoliteUpdate, userDeleteFromIDPEnabled) + srv := newServer(&server.Config{ + NbConfig: config, + DNSDomain: dnsDomain, + MgmtSingleAccModeDomain: mgmtSingleAccModeDomain, + MgmtPort: mgmtPort, + MgmtMetricsPort: mgmtMetricsPort, + DisableLegacyManagementPort: disableLegacyManagementPort, + DisableMetrics: disableMetrics, + DisableGeoliteUpdate: disableGeoliteUpdate, + UserDeleteFromIDPEnabled: userDeleteFromIDPEnabled, + }) go func() { if err := srv.Start(cmd.Context()); err != nil { log.Fatalf("Server error: %v", err) @@ -134,35 +145,35 @@ var ( } ) -func loadMgmtConfig(ctx context.Context, mgmtConfigPath string) (*nbconfig.Config, error) { +func LoadMgmtConfig(ctx context.Context, mgmtConfigPath string) (*nbconfig.Config, error) { loadedConfig := &nbconfig.Config{} if _, err := util.ReadJsonWithEnvSub(mgmtConfigPath, loadedConfig); err != nil { return nil, err } - applyCommandLineOverrides(loadedConfig) + ApplyCommandLineOverrides(loadedConfig) // Apply EmbeddedIdP config to HttpConfig if embedded IdP is enabled - err := applyEmbeddedIdPConfig(ctx, loadedConfig) + err := ApplyEmbeddedIdPConfig(ctx, loadedConfig) if err != nil { return nil, err } - if err := applyOIDCConfig(ctx, loadedConfig); err != nil { + if err := ApplyOIDCConfig(ctx, loadedConfig); err != nil { return nil, err } - logConfigInfo(loadedConfig) + LogConfigInfo(loadedConfig) - if err := ensureEncryptionKey(ctx, mgmtConfigPath, loadedConfig); err != nil { + if err := EnsureEncryptionKey(ctx, mgmtConfigPath, loadedConfig); err != nil { return nil, err } return loadedConfig, nil } -// applyCommandLineOverrides applies command-line flag overrides to the config -func applyCommandLineOverrides(cfg *nbconfig.Config) { +// ApplyCommandLineOverrides applies command-line flag overrides to the config +func ApplyCommandLineOverrides(cfg *nbconfig.Config) { if mgmtLetsencryptDomain != "" { cfg.HttpConfig.LetsEncryptDomain = mgmtLetsencryptDomain } @@ -175,9 +186,9 @@ func applyCommandLineOverrides(cfg *nbconfig.Config) { } } -// applyEmbeddedIdPConfig populates HttpConfig and EmbeddedIdP storage from config when embedded IdP is enabled. +// ApplyEmbeddedIdPConfig populates HttpConfig and EmbeddedIdP storage from config when embedded IdP is enabled. // This allows users to only specify EmbeddedIdP config without duplicating values in HttpConfig. -func applyEmbeddedIdPConfig(ctx context.Context, cfg *nbconfig.Config) error { +func ApplyEmbeddedIdPConfig(ctx context.Context, cfg *nbconfig.Config) error { if cfg.EmbeddedIdP == nil || !cfg.EmbeddedIdP.Enabled { return nil } @@ -214,17 +225,20 @@ func applyEmbeddedIdPConfig(ctx context.Context, cfg *nbconfig.Config) error { // Set HttpConfig values from EmbeddedIdP cfg.HttpConfig.AuthIssuer = issuer cfg.HttpConfig.AuthAudience = "netbird-dashboard" + cfg.HttpConfig.AuthClientID = cfg.HttpConfig.AuthAudience cfg.HttpConfig.CLIAuthAudience = "netbird-cli" cfg.HttpConfig.AuthUserIDClaim = "sub" cfg.HttpConfig.AuthKeysLocation = issuer + "/keys" cfg.HttpConfig.OIDCConfigEndpoint = issuer + "/.well-known/openid-configuration" cfg.HttpConfig.IdpSignKeyRefreshEnabled = true + callbackURL := strings.TrimSuffix(cfg.HttpConfig.AuthIssuer, "/oauth2") + cfg.HttpConfig.AuthCallbackURL = callbackURL + types.ProxyCallbackEndpointFull return nil } -// applyOIDCConfig fetches and applies OIDC configuration if endpoint is specified -func applyOIDCConfig(ctx context.Context, cfg *nbconfig.Config) error { +// ApplyOIDCConfig fetches and applies OIDC configuration if endpoint is specified +func ApplyOIDCConfig(ctx context.Context, cfg *nbconfig.Config) error { oidcEndpoint := cfg.HttpConfig.OIDCConfigEndpoint if oidcEndpoint == "" { return nil @@ -250,16 +264,16 @@ func applyOIDCConfig(ctx context.Context, cfg *nbconfig.Config) error { oidcConfig.JwksURI, cfg.HttpConfig.AuthKeysLocation) cfg.HttpConfig.AuthKeysLocation = oidcConfig.JwksURI - if err := applyDeviceAuthFlowConfig(ctx, cfg, &oidcConfig, oidcEndpoint); err != nil { + if err := ApplyDeviceAuthFlowConfig(ctx, cfg, &oidcConfig, oidcEndpoint); err != nil { return err } - applyPKCEFlowConfig(ctx, cfg, &oidcConfig) + ApplyPKCEFlowConfig(ctx, cfg, &oidcConfig) return nil } -// applyDeviceAuthFlowConfig applies OIDC config to DeviceAuthorizationFlow if enabled -func applyDeviceAuthFlowConfig(ctx context.Context, cfg *nbconfig.Config, oidcConfig *OIDCConfigResponse, oidcEndpoint string) error { +// ApplyDeviceAuthFlowConfig applies OIDC config to DeviceAuthorizationFlow if enabled +func ApplyDeviceAuthFlowConfig(ctx context.Context, cfg *nbconfig.Config, oidcConfig *OIDCConfigResponse, oidcEndpoint string) error { if cfg.DeviceAuthorizationFlow == nil || strings.ToLower(cfg.DeviceAuthorizationFlow.Provider) == string(nbconfig.NONE) { return nil } @@ -286,8 +300,8 @@ func applyDeviceAuthFlowConfig(ctx context.Context, cfg *nbconfig.Config, oidcCo return nil } -// applyPKCEFlowConfig applies OIDC config to PKCEAuthorizationFlow if configured -func applyPKCEFlowConfig(ctx context.Context, cfg *nbconfig.Config, oidcConfig *OIDCConfigResponse) { +// ApplyPKCEFlowConfig applies OIDC config to PKCEAuthorizationFlow if configured +func ApplyPKCEFlowConfig(ctx context.Context, cfg *nbconfig.Config, oidcConfig *OIDCConfigResponse) { if cfg.PKCEAuthorizationFlow == nil { return } @@ -300,8 +314,8 @@ func applyPKCEFlowConfig(ctx context.Context, cfg *nbconfig.Config, oidcConfig * cfg.PKCEAuthorizationFlow.ProviderConfig.AuthorizationEndpoint = oidcConfig.AuthorizationEndpoint } -// logConfigInfo logs informational messages about the loaded configuration -func logConfigInfo(cfg *nbconfig.Config) { +// LogConfigInfo logs informational messages about the loaded configuration +func LogConfigInfo(cfg *nbconfig.Config) { if cfg.EmbeddedIdP != nil { log.Infof("running with the embedded IdP: %v", cfg.EmbeddedIdP.Issuer) } @@ -310,8 +324,8 @@ func logConfigInfo(cfg *nbconfig.Config) { } } -// ensureEncryptionKey generates and saves a DataStoreEncryptionKey if not set -func ensureEncryptionKey(ctx context.Context, configPath string, cfg *nbconfig.Config) error { +// EnsureEncryptionKey generates and saves a DataStoreEncryptionKey if not set +func EnsureEncryptionKey(ctx context.Context, configPath string, cfg *nbconfig.Config) error { if cfg.DataStoreEncryptionKey != "" { return nil } diff --git a/management/cmd/management_test.go b/management/cmd/management_test.go index 244d86254..f0c89dd3f 100644 --- a/management/cmd/management_test.go +++ b/management/cmd/management_test.go @@ -30,7 +30,7 @@ func Test_loadMgmtConfig(t *testing.T) { t.Fatalf("failed to create config: %s", err) } - cfg, err := loadMgmtConfig(context.Background(), tmpFile) + cfg, err := LoadMgmtConfig(context.Background(), tmpFile) if err != nil { t.Fatalf("failed to load management config: %s", err) } diff --git a/management/cmd/root.go b/management/cmd/root.go index b60f79c23..fc43d315d 100644 --- a/management/cmd/root.go +++ b/management/cmd/root.go @@ -16,21 +16,22 @@ const ( ) var ( - dnsDomain string - mgmtDataDir string - logLevel string - logFile string - disableMetrics bool - disableSingleAccMode bool - disableGeoliteUpdate bool - idpSignKeyRefreshEnabled bool - userDeleteFromIDPEnabled bool - mgmtPort int - mgmtMetricsPort int - mgmtLetsencryptDomain string - mgmtSingleAccModeDomain string - certFile string - certKey string + dnsDomain string + mgmtDataDir string + logLevel string + logFile string + disableMetrics bool + disableSingleAccMode bool + disableGeoliteUpdate bool + idpSignKeyRefreshEnabled bool + userDeleteFromIDPEnabled bool + mgmtPort int + mgmtMetricsPort int + disableLegacyManagementPort bool + mgmtLetsencryptDomain string + mgmtSingleAccModeDomain string + certFile string + certKey string rootCmd = &cobra.Command{ Use: "netbird-mgmt", @@ -55,6 +56,7 @@ func Execute() error { func init() { mgmtCmd.Flags().IntVar(&mgmtPort, "port", 80, "server port to listen on (defaults to 443 if TLS is enabled, 80 otherwise") + mgmtCmd.Flags().BoolVar(&disableLegacyManagementPort, "disable-legacy-port", false, "disabling the old legacy port (33073)") mgmtCmd.Flags().IntVar(&mgmtMetricsPort, "metrics-port", 9090, "metrics endpoint http port. Metrics are accessible under host:metrics-port/metrics") mgmtCmd.Flags().StringVar(&mgmtDataDir, "datadir", defaultMgmtDataDir, "server data directory location") mgmtCmd.Flags().StringVar(&nbconfig.MgmtConfigPath, "config", defaultMgmtConfig, "Netbird config file location. Config params specified via command line (e.g. datadir) have a precedence over configuration from this file") @@ -80,4 +82,8 @@ func init() { migrationCmd.AddCommand(upCmd) rootCmd.AddCommand(migrationCmd) + + tc := newTokenCommands() + tc.PersistentFlags().StringVar(&nbconfig.MgmtConfigPath, "config", defaultMgmtConfig, "Netbird config file location") + rootCmd.AddCommand(tc) } diff --git a/management/cmd/token.go b/management/cmd/token.go new file mode 100644 index 000000000..67af1a5f5 --- /dev/null +++ b/management/cmd/token.go @@ -0,0 +1,55 @@ +package cmd + +import ( + "context" + "fmt" + + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "github.com/netbirdio/netbird/formatter/hook" + tokencmd "github.com/netbirdio/netbird/management/cmd/token" + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/util" +) + +var tokenDatadir string + +// newTokenCommands creates the token command tree with management-specific store opener. +func newTokenCommands() *cobra.Command { + cmd := tokencmd.NewCommands(withTokenStore) + cmd.PersistentFlags().StringVar(&tokenDatadir, "datadir", "", "Override the data directory from config (where store.db is located)") + return cmd +} + +// withTokenStore initializes logging, loads config, opens the store, and calls fn. +func withTokenStore(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error { + if err := util.InitLog("error", "console"); err != nil { + return fmt.Errorf("init log: %w", err) + } + + ctx := context.WithValue(cmd.Context(), hook.ExecutionContextKey, hook.SystemSource) //nolint:staticcheck + + config, err := LoadMgmtConfig(ctx, nbconfig.MgmtConfigPath) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + datadir := config.Datadir + if tokenDatadir != "" { + datadir = tokenDatadir + } + + s, err := store.NewStore(ctx, config.StoreConfig.Engine, datadir, nil, true) + if err != nil { + return fmt.Errorf("create store: %w", err) + } + defer func() { + if err := s.Close(ctx); err != nil { + log.Debugf("close store: %v", err) + } + }() + + return fn(ctx, s) +} diff --git a/management/cmd/token/token.go b/management/cmd/token/token.go new file mode 100644 index 000000000..fb89e732c --- /dev/null +++ b/management/cmd/token/token.go @@ -0,0 +1,185 @@ +// Package tokencmd provides reusable cobra commands for managing proxy access tokens. +// Both the management and combined binaries use these commands, each providing +// their own StoreOpener to handle config loading and store initialization. +package tokencmd + +import ( + "context" + "fmt" + "io" + "strconv" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" +) + +// StoreOpener initializes a store from the command context and calls fn. +type StoreOpener func(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error + +// NewCommands creates the token command tree with the given store opener. +// Returns the parent "token" command with create, list, and revoke subcommands. +func NewCommands(opener StoreOpener) *cobra.Command { + var ( + tokenName string + tokenExpireIn string + ) + + tokenCmd := &cobra.Command{ + Use: "token", + Short: "Manage proxy access tokens", + Long: "Commands for creating, listing, and revoking proxy access tokens used by reverse proxy instances to authenticate with the management server.", + } + + createCmd := &cobra.Command{ + Use: "create", + Short: "Create a new proxy access token", + Long: "Creates a new proxy access token. The plain text token is displayed only once at creation time.", + RunE: func(cmd *cobra.Command, _ []string) error { + return opener(cmd, func(ctx context.Context, s store.Store) error { + return runCreate(ctx, s, cmd.OutOrStdout(), tokenName, tokenExpireIn) + }) + }, + } + createCmd.Flags().StringVar(&tokenName, "name", "", "Name for the token (required)") + createCmd.Flags().StringVar(&tokenExpireIn, "expires-in", "", "Token expiration duration (e.g., 365d, 24h, 30d). Empty means no expiration") + if err := createCmd.MarkFlagRequired("name"); err != nil { + panic(err) + } + + listCmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List all proxy access tokens", + Long: "Lists all proxy access tokens with their IDs, names, creation dates, expiration, and revocation status.", + RunE: func(cmd *cobra.Command, _ []string) error { + return opener(cmd, func(ctx context.Context, s store.Store) error { + return runList(ctx, s, cmd.OutOrStdout()) + }) + }, + } + + revokeCmd := &cobra.Command{ + Use: "revoke [token-id]", + Short: "Revoke a proxy access token", + Long: "Revokes a proxy access token by its ID. Revoked tokens can no longer be used for authentication.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return opener(cmd, func(ctx context.Context, s store.Store) error { + return runRevoke(ctx, s, cmd.OutOrStdout(), args[0]) + }) + }, + } + + tokenCmd.AddCommand(createCmd, listCmd, revokeCmd) + return tokenCmd +} + +func runCreate(ctx context.Context, s store.Store, w io.Writer, name string, expireIn string) error { + expiresIn, err := ParseDuration(expireIn) + if err != nil { + return fmt.Errorf("parse expiration: %w", err) + } + + generated, err := types.CreateNewProxyAccessToken(name, expiresIn, nil, "CLI") + if err != nil { + return fmt.Errorf("generate token: %w", err) + } + + if err := s.SaveProxyAccessToken(ctx, &generated.ProxyAccessToken); err != nil { + return fmt.Errorf("save token: %w", err) + } + + _, _ = fmt.Fprintln(w, "Token created successfully!") + _, _ = fmt.Fprintf(w, "Token: %s\n", generated.PlainToken) + _, _ = fmt.Fprintln(w) + _, _ = fmt.Fprintln(w, "IMPORTANT: Save this token now. It will not be shown again.") + _, _ = fmt.Fprintf(w, "Token ID: %s\n", generated.ID) + return nil +} + +func runList(ctx context.Context, s store.Store, out io.Writer) error { + tokens, err := s.GetAllProxyAccessTokens(ctx, store.LockingStrengthNone) + if err != nil { + return fmt.Errorf("list tokens: %w", err) + } + + if len(tokens) == 0 { + _, _ = fmt.Fprintln(out, "No proxy access tokens found.") + return nil + } + + w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "ID\tNAME\tCREATED\tEXPIRES\tLAST USED\tREVOKED") + _, _ = fmt.Fprintln(w, "--\t----\t-------\t-------\t---------\t-------") + + for _, t := range tokens { + expires := "never" + if t.ExpiresAt != nil { + expires = t.ExpiresAt.Format("2006-01-02") + } + + lastUsed := "never" + if t.LastUsed != nil { + lastUsed = t.LastUsed.Format("2006-01-02 15:04") + } + + revoked := "no" + if t.Revoked { + revoked = "yes" + } + + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", + t.ID, + t.Name, + t.CreatedAt.Format("2006-01-02"), + expires, + lastUsed, + revoked, + ) + } + + w.Flush() + + return nil +} + +func runRevoke(ctx context.Context, s store.Store, w io.Writer, tokenID string) error { + if err := s.RevokeProxyAccessToken(ctx, tokenID); err != nil { + return fmt.Errorf("revoke token: %w", err) + } + + _, _ = fmt.Fprintf(w, "Token %s revoked successfully.\n", tokenID) + return nil +} + +// ParseDuration parses a duration string with support for days (e.g., "30d", "365d"). +// An empty string returns zero duration (no expiration). +func ParseDuration(s string) (time.Duration, error) { + if len(s) == 0 { + return 0, nil + } + + if s[len(s)-1] == 'd' { + d, err := strconv.Atoi(s[:len(s)-1]) + if err != nil { + return 0, fmt.Errorf("invalid day format: %s", s) + } + if d <= 0 { + return 0, fmt.Errorf("duration must be positive: %s", s) + } + return time.Duration(d) * 24 * time.Hour, nil + } + + d, err := time.ParseDuration(s) + if err != nil { + return 0, err + } + if d <= 0 { + return 0, fmt.Errorf("duration must be positive: %s", s) + } + return d, nil +} diff --git a/management/cmd/token/token_test.go b/management/cmd/token/token_test.go new file mode 100644 index 000000000..d554bbe45 --- /dev/null +++ b/management/cmd/token/token_test.go @@ -0,0 +1,101 @@ +package tokencmd + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseDuration(t *testing.T) { + tests := []struct { + name string + input string + expected time.Duration + wantErr bool + }{ + { + name: "empty string returns zero", + input: "", + expected: 0, + }, + { + name: "days suffix", + input: "30d", + expected: 30 * 24 * time.Hour, + }, + { + name: "one day", + input: "1d", + expected: 24 * time.Hour, + }, + { + name: "365 days", + input: "365d", + expected: 365 * 24 * time.Hour, + }, + { + name: "hours via Go duration", + input: "24h", + expected: 24 * time.Hour, + }, + { + name: "minutes via Go duration", + input: "30m", + expected: 30 * time.Minute, + }, + { + name: "complex Go duration", + input: "1h30m", + expected: 90 * time.Minute, + }, + { + name: "invalid day format", + input: "abcd", + wantErr: true, + }, + { + name: "negative days", + input: "-1d", + wantErr: true, + }, + { + name: "zero days", + input: "0d", + wantErr: true, + }, + { + name: "non-numeric days", + input: "xyzd", + wantErr: true, + }, + { + name: "negative Go duration", + input: "-24h", + wantErr: true, + }, + { + name: "zero Go duration", + input: "0s", + wantErr: true, + }, + { + name: "invalid Go duration", + input: "notaduration", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ParseDuration(tt.input) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go index 5ae64e9f1..4b414df6f 100644 --- a/management/internals/controllers/network_map/controller/controller.go +++ b/management/internals/controllers/network_map/controller/controller.go @@ -63,6 +63,8 @@ type Controller struct { expNewNetworkMap bool expNewNetworkMapAIDs map[string]struct{} + + compactedNetworkMap bool } type bufferUpdate struct { @@ -85,6 +87,17 @@ func NewController(ctx context.Context, store store.Store, metrics telemetry.App newNetworkMapBuilder = false } + compactedNetworkMap := true + compactedEnv := os.Getenv(types.EnvNewNetworkMapCompacted) + parsedCompactedNmap, err := strconv.ParseBool(compactedEnv) + if err != nil && len(compactedEnv) > 0 { + log.WithContext(ctx).Warnf("failed to parse %s, using default value true: %v", types.EnvNewNetworkMapCompacted, err) + } + if err == nil && !parsedCompactedNmap { + log.WithContext(ctx).Info("disabling compacted mode") + compactedNetworkMap = false + } + ids := strings.Split(os.Getenv(network_map.EnvNewNetworkMapAccounts), ",") expIDs := make(map[string]struct{}, len(ids)) for _, id := range ids { @@ -108,6 +121,8 @@ func NewController(ctx context.Context, store store.Store, metrics telemetry.App holder: types.NewHolder(), expNewNetworkMap: newNetworkMapBuilder, expNewNetworkMapAIDs: expIDs, + + compactedNetworkMap: compactedNetworkMap, } } @@ -174,6 +189,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin var wg sync.WaitGroup semaphore := make(chan struct{}, 10) + account.InjectProxyPolicies(ctx) dnsCache := &cache.DNSConfigCache{} dnsDomain := c.GetDNSDomain(account.Settings) peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain) @@ -229,9 +245,12 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin var remotePeerNetworkMap *types.NetworkMap - if c.experimentalNetworkMap(accountID) { + switch { + case c.experimentalNetworkMap(accountID): remotePeerNetworkMap = c.getPeerNetworkMapExp(ctx, p.AccountID, p.ID, approvedPeersMap, peersCustomZone, accountZones, c.accountManagerMetrics) - } else { + case c.compactedNetworkMap: + remotePeerNetworkMap = account.GetPeerNetworkMapFromComponents(ctx, p.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) + default: remotePeerNetworkMap = account.GetPeerNetworkMap(ctx, p.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) } @@ -247,7 +266,10 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin update := grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, remotePeerNetworkMap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort) c.metrics.CountToSyncResponseDuration(time.Since(start)) - c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{Update: update}) + c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{ + Update: update, + MessageType: network_map.MessageTypeNetworkMap, + }) }(peer) } @@ -323,6 +345,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe return fmt.Errorf("failed to get validated peers: %v", err) } + account.InjectProxyPolicies(ctx) dnsCache := &cache.DNSConfigCache{} dnsDomain := c.GetDNSDomain(account.Settings) peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain) @@ -350,9 +373,12 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe var remotePeerNetworkMap *types.NetworkMap - if c.experimentalNetworkMap(accountId) { + switch { + case c.experimentalNetworkMap(accountId): remotePeerNetworkMap = c.getPeerNetworkMapExp(ctx, peer.AccountID, peer.ID, approvedPeersMap, peersCustomZone, accountZones, c.accountManagerMetrics) - } else { + case c.compactedNetworkMap: + remotePeerNetworkMap = account.GetPeerNetworkMapFromComponents(ctx, peerId, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) + default: remotePeerNetworkMap = account.GetPeerNetworkMap(ctx, peerId, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) } @@ -370,7 +396,10 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe dnsFwdPort := computeForwarderPort(maps.Values(account.Peers), network_map.DnsForwarderPortMinVersion) update := grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, peer, nil, nil, remotePeerNetworkMap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSettings, maps.Keys(peerGroups), dnsFwdPort) - c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{Update: update}) + c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{ + Update: update, + MessageType: network_map.MessageTypeNetworkMap, + }) return nil } @@ -435,6 +464,8 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr } } + account.InjectProxyPolicies(ctx) + approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra) if err != nil { return nil, nil, nil, 0, err @@ -469,7 +500,12 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr } else { resourcePolicies := account.GetResourcePoliciesMap() routers := account.GetResourceRoutersMap() - networkMap = account.GetPeerNetworkMap(ctx, peer.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, account.GetActiveGroupUsers()) + groupIDToUserIDs := account.GetActiveGroupUsers() + if c.compactedNetworkMap { + networkMap = account.GetPeerNetworkMapFromComponents(ctx, peer.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) + } else { + networkMap = account.GetPeerNetworkMap(ctx, peer.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) + } } proxyNetworkMap, ok := proxyNetworkMaps[peer.ID] @@ -778,6 +814,7 @@ func (c *Controller) OnPeersDeleted(ctx context.Context, accountID string, peerI }, }, }, + MessageType: network_map.MessageTypeNetworkMap, }) c.peersUpdateManager.CloseChannel(ctx, peerID) @@ -840,9 +877,15 @@ func (c *Controller) GetNetworkMap(ctx context.Context, peerID string) (*types.N if c.experimentalNetworkMap(peer.AccountID) { networkMap = c.getPeerNetworkMapExp(ctx, peer.AccountID, peerID, validatedPeers, peersCustomZone, accountZones, nil) } else { + account.InjectProxyPolicies(ctx) resourcePolicies := account.GetResourcePoliciesMap() routers := account.GetResourceRoutersMap() - networkMap = account.GetPeerNetworkMap(ctx, peer.ID, peersCustomZone, accountZones, validatedPeers, resourcePolicies, routers, nil, account.GetActiveGroupUsers()) + groupIDToUserIDs := account.GetActiveGroupUsers() + if c.compactedNetworkMap { + networkMap = account.GetPeerNetworkMapFromComponents(ctx, peer.ID, peersCustomZone, accountZones, validatedPeers, resourcePolicies, routers, nil, groupIDToUserIDs) + } else { + networkMap = account.GetPeerNetworkMap(ctx, peer.ID, peersCustomZone, accountZones, validatedPeers, resourcePolicies, routers, nil, groupIDToUserIDs) + } } proxyNetworkMap, ok := proxyNetworkMaps[peer.ID] diff --git a/management/internals/controllers/network_map/update_channel/updatechannel_test.go b/management/internals/controllers/network_map/update_channel/updatechannel_test.go index afc1e2c32..c73baf81f 100644 --- a/management/internals/controllers/network_map/update_channel/updatechannel_test.go +++ b/management/internals/controllers/network_map/update_channel/updatechannel_test.go @@ -25,11 +25,14 @@ func TestCreateChannel(t *testing.T) { func TestSendUpdate(t *testing.T) { peer := "test-sendupdate" peersUpdater := NewPeersUpdateManager(nil) - update1 := &network_map.UpdateMessage{Update: &proto.SyncResponse{ - NetworkMap: &proto.NetworkMap{ - Serial: 0, + update1 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{ + NetworkMap: &proto.NetworkMap{ + Serial: 0, + }, }, - }} + MessageType: network_map.MessageTypeNetworkMap, + } _ = peersUpdater.CreateChannel(context.Background(), peer) if _, ok := peersUpdater.peerChannels[peer]; !ok { t.Error("Error creating the channel") @@ -45,11 +48,14 @@ func TestSendUpdate(t *testing.T) { peersUpdater.SendUpdate(context.Background(), peer, update1) } - update2 := &network_map.UpdateMessage{Update: &proto.SyncResponse{ - NetworkMap: &proto.NetworkMap{ - Serial: 10, + update2 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{ + NetworkMap: &proto.NetworkMap{ + Serial: 10, + }, }, - }} + MessageType: network_map.MessageTypeNetworkMap, + } peersUpdater.SendUpdate(context.Background(), peer, update2) timeout := time.After(5 * time.Second) diff --git a/management/internals/controllers/network_map/update_message.go b/management/internals/controllers/network_map/update_message.go index 33643bcbd..0ffddf8b2 100644 --- a/management/internals/controllers/network_map/update_message.go +++ b/management/internals/controllers/network_map/update_message.go @@ -4,6 +4,19 @@ import ( "github.com/netbirdio/netbird/shared/management/proto" ) +// MessageType indicates the type of update message for debouncing strategy +type MessageType int + +const ( + // MessageTypeNetworkMap represents network map updates (peers, routes, DNS, firewall) + // These updates can be safely debounced - only the latest state matters + MessageTypeNetworkMap MessageType = iota + // MessageTypeControlConfig represents control/config updates (tokens, peer expiration) + // These updates should not be dropped as they contain time-sensitive information + MessageTypeControlConfig +) + type UpdateMessage struct { - Update *proto.SyncResponse + Update *proto.SyncResponse + MessageType MessageType } diff --git a/management/internals/modules/peers/ephemeral/manager/ephemeral.go b/management/internals/modules/peers/ephemeral/manager/ephemeral.go index 15119045b..758f643d0 100644 --- a/management/internals/modules/peers/ephemeral/manager/ephemeral.go +++ b/management/internals/modules/peers/ephemeral/manager/ephemeral.go @@ -187,10 +187,10 @@ func (e *EphemeralManager) cleanup(ctx context.Context) { } for accountID, peerIDs := range peerIDsPerAccount { - log.WithContext(ctx).Debugf("delete ephemeral peers for account: %s", accountID) + log.WithContext(ctx).Tracef("cleanup: deleting %d ephemeral peers for account %s", len(peerIDs), accountID) err := e.peersManager.DeletePeers(ctx, accountID, peerIDs, activity.SystemInitiator, true) if err != nil { - log.WithContext(ctx).Errorf("failed to delete ephemeral peer: %s", err) + log.WithContext(ctx).Errorf("failed to delete ephemeral peers: %s", err) } } } diff --git a/management/internals/modules/peers/manager.go b/management/internals/modules/peers/manager.go index 1551689b4..7cb0f3908 100644 --- a/management/internals/modules/peers/manager.go +++ b/management/internals/modules/peers/manager.go @@ -7,6 +7,7 @@ import ( "fmt" "time" + "github.com/rs/xid" log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/management/internals/controllers/network_map" @@ -32,6 +33,7 @@ type Manager interface { SetIntegratedPeerValidator(integratedPeerValidator integrated_validator.IntegratedValidator) SetAccountManager(accountManager account.Manager) GetPeerID(ctx context.Context, peerKey string) (string, error) + CreateProxyPeer(ctx context.Context, accountID string, peerKey string, cluster string) error } type managerImpl struct { @@ -108,10 +110,19 @@ func (m *managerImpl) DeletePeers(ctx context.Context, accountID string, peerIDs err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { peer, err := transaction.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID) if err != nil { + if e, ok := status.FromError(err); ok && e.Type() == status.NotFound { + log.WithContext(ctx).Tracef("DeletePeers: peer %s not found, skipping", peerID) + return nil + } return err } if checkConnected && (peer.Status.Connected || peer.Status.LastSeen.After(time.Now().Add(-(ephemeral.EphemeralLifeTime - 10*time.Second)))) { + log.WithContext(ctx).Tracef("DeletePeers: peer %s skipped (connected=%t, lastSeen=%s, threshold=%s, ephemeral=%t)", + peerID, peer.Status.Connected, + peer.Status.LastSeen.Format(time.RFC3339), + time.Now().Add(-(ephemeral.EphemeralLifeTime - 10*time.Second)).Format(time.RFC3339), + peer.Ephemeral) return nil } @@ -150,7 +161,8 @@ func (m *managerImpl) DeletePeers(ctx context.Context, accountID string, peerIDs return nil }) if err != nil { - return err + log.WithContext(ctx).Errorf("DeletePeers: failed to delete peer %s: %v", peerID, err) + continue } if m.integratedPeerValidator != nil { @@ -172,3 +184,36 @@ func (m *managerImpl) DeletePeers(ctx context.Context, accountID string, peerIDs func (m *managerImpl) GetPeerID(ctx context.Context, peerKey string) (string, error) { return m.store.GetPeerIDByKey(ctx, store.LockingStrengthNone, peerKey) } + +func (m *managerImpl) CreateProxyPeer(ctx context.Context, accountID string, peerKey string, cluster string) error { + existingPeerID, err := m.store.GetPeerIDByKey(ctx, store.LockingStrengthNone, peerKey) + if err == nil && existingPeerID != "" { + // Peer already exists + return nil + } + + name := fmt.Sprintf("proxy-%s", xid.New().String()) + peer := &peer.Peer{ + Ephemeral: true, + ProxyMeta: peer.ProxyMeta{ + Cluster: cluster, + Embedded: true, + }, + Name: name, + Key: peerKey, + LoginExpirationEnabled: false, + InactivityExpirationEnabled: false, + Meta: peer.PeerSystemMeta{ + Hostname: name, + GoOS: "proxy", + OS: "proxy", + }, + } + + _, _, _, err = m.accountManager.AddPeer(ctx, accountID, "", "", peer, true) + if err != nil { + return fmt.Errorf("failed to create proxy peer: %w", err) + } + + return nil +} diff --git a/management/internals/modules/peers/manager_mock.go b/management/internals/modules/peers/manager_mock.go index 6feedca2e..d6c9ebacc 100644 --- a/management/internals/modules/peers/manager_mock.go +++ b/management/internals/modules/peers/manager_mock.go @@ -162,3 +162,17 @@ func (mr *MockManagerMockRecorder) SetNetworkMapController(networkMapController mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetNetworkMapController", reflect.TypeOf((*MockManager)(nil).SetNetworkMapController), networkMapController) } + +// CreateProxyPeer mocks base method. +func (m *MockManager) CreateProxyPeer(ctx context.Context, accountID string, peerKey string, cluster string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateProxyPeer", ctx, accountID, peerKey, cluster) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateProxyPeer indicates an expected call of CreateProxyPeer. +func (mr *MockManagerMockRecorder) CreateProxyPeer(ctx, accountID, peerKey, cluster interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateProxyPeer", reflect.TypeOf((*MockManager)(nil).CreateProxyPeer), ctx, accountID, peerKey, cluster) +} diff --git a/management/internals/modules/reverseproxy/accesslogs/accesslogentry.go b/management/internals/modules/reverseproxy/accesslogs/accesslogentry.go new file mode 100644 index 000000000..a7f692569 --- /dev/null +++ b/management/internals/modules/reverseproxy/accesslogs/accesslogentry.go @@ -0,0 +1,140 @@ +package accesslogs + +import ( + "net" + "net/netip" + "time" + + "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// AccessLogProtocol identifies the transport protocol of an access log entry. +type AccessLogProtocol string + +const ( + AccessLogProtocolHTTP AccessLogProtocol = "http" + AccessLogProtocolTCP AccessLogProtocol = "tcp" + AccessLogProtocolUDP AccessLogProtocol = "udp" +) + +type AccessLogEntry struct { + ID string `gorm:"primaryKey"` + AccountID string `gorm:"index"` + ServiceID string `gorm:"index"` + Timestamp time.Time `gorm:"index"` + GeoLocation peer.Location `gorm:"embedded;embeddedPrefix:location_"` + SubdivisionCode string + Method string `gorm:"index"` + Host string `gorm:"index"` + Path string `gorm:"index"` + Duration time.Duration `gorm:"index"` + StatusCode int `gorm:"index"` + Reason string + UserId string `gorm:"index"` + AuthMethodUsed string `gorm:"index"` + BytesUpload int64 `gorm:"index"` + BytesDownload int64 `gorm:"index"` + Protocol AccessLogProtocol `gorm:"index"` +} + +// FromProto creates an AccessLogEntry from a proto.AccessLog +func (a *AccessLogEntry) FromProto(serviceLog *proto.AccessLog) { + a.ID = serviceLog.GetLogId() + a.ServiceID = serviceLog.GetServiceId() + a.Timestamp = serviceLog.GetTimestamp().AsTime() + a.Method = serviceLog.GetMethod() + a.Host = serviceLog.GetHost() + a.Path = serviceLog.GetPath() + a.Duration = time.Duration(serviceLog.GetDurationMs()) * time.Millisecond + a.StatusCode = int(serviceLog.GetResponseCode()) + a.UserId = serviceLog.GetUserId() + a.AuthMethodUsed = serviceLog.GetAuthMechanism() + a.AccountID = serviceLog.GetAccountId() + a.BytesUpload = serviceLog.GetBytesUpload() + a.BytesDownload = serviceLog.GetBytesDownload() + a.Protocol = AccessLogProtocol(serviceLog.GetProtocol()) + + if sourceIP := serviceLog.GetSourceIp(); sourceIP != "" { + if addr, err := netip.ParseAddr(sourceIP); err == nil { + addr = addr.Unmap() + a.GeoLocation.ConnectionIP = net.IP(addr.AsSlice()) + } + } + + // Only set reason for HTTP entries. L4 entries have no auth or status code. + if a.Protocol == "" || a.Protocol == AccessLogProtocolHTTP { + if !serviceLog.GetAuthSuccess() { + a.Reason = "Authentication failed" + } else if serviceLog.GetResponseCode() >= 400 { + a.Reason = "Request failed" + } + } +} + +// ToAPIResponse converts an AccessLogEntry to the API ProxyAccessLog type +func (a *AccessLogEntry) ToAPIResponse() *api.ProxyAccessLog { + var sourceIP *string + if a.GeoLocation.ConnectionIP != nil { + ip := a.GeoLocation.ConnectionIP.String() + sourceIP = &ip + } + + var reason *string + if a.Reason != "" { + reason = &a.Reason + } + + var userID *string + if a.UserId != "" { + userID = &a.UserId + } + + var authMethod *string + if a.AuthMethodUsed != "" { + authMethod = &a.AuthMethodUsed + } + + var countryCode *string + if a.GeoLocation.CountryCode != "" { + countryCode = &a.GeoLocation.CountryCode + } + + var cityName *string + if a.GeoLocation.CityName != "" { + cityName = &a.GeoLocation.CityName + } + + var subdivisionCode *string + if a.SubdivisionCode != "" { + subdivisionCode = &a.SubdivisionCode + } + + var protocol *string + if a.Protocol != "" { + p := string(a.Protocol) + protocol = &p + } + + return &api.ProxyAccessLog{ + Id: a.ID, + ServiceId: a.ServiceID, + Timestamp: a.Timestamp, + Method: a.Method, + Host: a.Host, + Path: a.Path, + DurationMs: int(a.Duration.Milliseconds()), + StatusCode: a.StatusCode, + SourceIp: sourceIP, + Reason: reason, + UserId: userID, + AuthMethodUsed: authMethod, + CountryCode: countryCode, + CityName: cityName, + SubdivisionCode: subdivisionCode, + BytesUpload: a.BytesUpload, + BytesDownload: a.BytesDownload, + Protocol: protocol, + } +} diff --git a/management/internals/modules/reverseproxy/accesslogs/filter.go b/management/internals/modules/reverseproxy/accesslogs/filter.go new file mode 100644 index 000000000..a1fa28312 --- /dev/null +++ b/management/internals/modules/reverseproxy/accesslogs/filter.go @@ -0,0 +1,178 @@ +package accesslogs + +import ( + "net/http" + "strconv" + "strings" + "time" +) + +const ( + // DefaultPageSize is the default number of records per page + DefaultPageSize = 50 + // MaxPageSize is the maximum number of records allowed per page + MaxPageSize = 100 + + // Default sorting + DefaultSortBy = "timestamp" + DefaultSortOrder = "desc" +) + +// Valid sortable fields mapped to their database column names or expressions +// For multi-column sorts, columns are separated by comma (e.g., "host, path") +var validSortFields = map[string]string{ + "timestamp": "timestamp", + "url": "host, path", // Sort by host first, then path + "host": "host", + "path": "path", + "method": "method", + "status_code": "status_code", + "duration": "duration", + "source_ip": "location_connection_ip", + "user_id": "user_id", + "auth_method": "auth_method_used", + "reason": "reason", +} + +// AccessLogFilter holds pagination, filtering, and sorting parameters for access logs +type AccessLogFilter struct { + // Page is the current page number (1-indexed) + Page int + // PageSize is the number of records per page + PageSize int + + // Sorting parameters + SortBy string // Field to sort by: timestamp, url, host, path, method, status_code, duration, source_ip, user_id, auth_method, reason + SortOrder string // Sort order: asc or desc (default: desc) + + // Filtering parameters + Search *string // General search across log ID, host, path, source IP, and user fields + SourceIP *string // Filter by source IP address + Host *string // Filter by host header + Path *string // Filter by request path (supports LIKE pattern) + UserID *string // Filter by authenticated user ID + UserEmail *string // Filter by user email (requires user lookup) + UserName *string // Filter by user name (requires user lookup) + Method *string // Filter by HTTP method + Status *string // Filter by status: "success" (2xx/3xx) or "failed" (1xx/4xx/5xx) + StatusCode *int // Filter by HTTP status code + StartDate *time.Time // Filter by timestamp >= start_date + EndDate *time.Time // Filter by timestamp <= end_date +} + +// ParseFromRequest parses pagination, sorting, and filter parameters from HTTP request query parameters +func (f *AccessLogFilter) ParseFromRequest(r *http.Request) { + queryParams := r.URL.Query() + + f.Page = parsePositiveInt(queryParams.Get("page"), 1) + f.PageSize = min(parsePositiveInt(queryParams.Get("page_size"), DefaultPageSize), MaxPageSize) + + f.SortBy = parseSortField(queryParams.Get("sort_by")) + f.SortOrder = parseSortOrder(queryParams.Get("sort_order")) + + f.Search = parseOptionalString(queryParams.Get("search")) + f.SourceIP = parseOptionalString(queryParams.Get("source_ip")) + f.Host = parseOptionalString(queryParams.Get("host")) + f.Path = parseOptionalString(queryParams.Get("path")) + f.UserID = parseOptionalString(queryParams.Get("user_id")) + f.UserEmail = parseOptionalString(queryParams.Get("user_email")) + f.UserName = parseOptionalString(queryParams.Get("user_name")) + f.Method = parseOptionalString(queryParams.Get("method")) + f.Status = parseOptionalString(queryParams.Get("status")) + f.StatusCode = parseOptionalInt(queryParams.Get("status_code")) + f.StartDate = parseOptionalRFC3339(queryParams.Get("start_date")) + f.EndDate = parseOptionalRFC3339(queryParams.Get("end_date")) +} + +// parsePositiveInt parses a positive integer from a string, returning defaultValue if invalid +func parsePositiveInt(s string, defaultValue int) int { + if s == "" { + return defaultValue + } + if val, err := strconv.Atoi(s); err == nil && val > 0 { + return val + } + return defaultValue +} + +// parseOptionalString returns a pointer to the string if non-empty, otherwise nil +func parseOptionalString(s string) *string { + if s == "" { + return nil + } + return &s +} + +// parseOptionalInt parses an optional positive integer from a string +func parseOptionalInt(s string) *int { + if s == "" { + return nil + } + if val, err := strconv.Atoi(s); err == nil && val > 0 { + v := val + return &v + } + return nil +} + +// parseOptionalRFC3339 parses an optional RFC3339 timestamp from a string +func parseOptionalRFC3339(s string) *time.Time { + if s == "" { + return nil + } + if t, err := time.Parse(time.RFC3339, s); err == nil { + return &t + } + return nil +} + +// GetOffset calculates the database offset for pagination +func (f *AccessLogFilter) GetOffset() int { + return (f.Page - 1) * f.PageSize +} + +// GetLimit returns the page size for database queries +func (f *AccessLogFilter) GetLimit() int { + return f.PageSize +} + +// GetSortColumn returns the validated database column name for sorting +func (f *AccessLogFilter) GetSortColumn() string { + if column, ok := validSortFields[f.SortBy]; ok { + return column + } + return validSortFields[DefaultSortBy] +} + +// GetSortOrder returns the validated sort order (ASC or DESC) +func (f *AccessLogFilter) GetSortOrder() string { + if f.SortOrder == "asc" || f.SortOrder == "desc" { + return f.SortOrder + } + return DefaultSortOrder +} + +// parseSortField validates and returns the sort field, defaulting if invalid +func parseSortField(s string) string { + if s == "" { + return DefaultSortBy + } + // Check if the field is valid + if _, ok := validSortFields[s]; ok { + return s + } + return DefaultSortBy +} + +// parseSortOrder validates and returns the sort order, defaulting if invalid +func parseSortOrder(s string) string { + if s == "" { + return DefaultSortOrder + } + // Normalize to lowercase + s = strings.ToLower(s) + if s == "asc" || s == "desc" { + return s + } + return DefaultSortOrder +} diff --git a/management/internals/modules/reverseproxy/accesslogs/filter_test.go b/management/internals/modules/reverseproxy/accesslogs/filter_test.go new file mode 100644 index 000000000..ea1fce54b --- /dev/null +++ b/management/internals/modules/reverseproxy/accesslogs/filter_test.go @@ -0,0 +1,570 @@ +package accesslogs + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAccessLogFilter_ParseFromRequest(t *testing.T) { + tests := []struct { + name string + queryParams map[string]string + expectedPage int + expectedPageSize int + }{ + { + name: "default values when no params provided", + queryParams: map[string]string{}, + expectedPage: 1, + expectedPageSize: DefaultPageSize, + }, + { + name: "valid page and page_size", + queryParams: map[string]string{ + "page": "2", + "page_size": "25", + }, + expectedPage: 2, + expectedPageSize: 25, + }, + { + name: "page_size exceeds max, should cap at MaxPageSize", + queryParams: map[string]string{ + "page": "1", + "page_size": "200", + }, + expectedPage: 1, + expectedPageSize: MaxPageSize, + }, + { + name: "invalid page number, should use default", + queryParams: map[string]string{ + "page": "invalid", + "page_size": "10", + }, + expectedPage: 1, + expectedPageSize: 10, + }, + { + name: "invalid page_size, should use default", + queryParams: map[string]string{ + "page": "2", + "page_size": "invalid", + }, + expectedPage: 2, + expectedPageSize: DefaultPageSize, + }, + { + name: "zero page number, should use default", + queryParams: map[string]string{ + "page": "0", + "page_size": "10", + }, + expectedPage: 1, + expectedPageSize: 10, + }, + { + name: "negative page number, should use default", + queryParams: map[string]string{ + "page": "-1", + "page_size": "10", + }, + expectedPage: 1, + expectedPageSize: 10, + }, + { + name: "zero page_size, should use default", + queryParams: map[string]string{ + "page": "1", + "page_size": "0", + }, + expectedPage: 1, + expectedPageSize: DefaultPageSize, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + q := req.URL.Query() + for key, value := range tt.queryParams { + q.Set(key, value) + } + req.URL.RawQuery = q.Encode() + + filter := &AccessLogFilter{} + filter.ParseFromRequest(req) + + assert.Equal(t, tt.expectedPage, filter.Page, "Page mismatch") + assert.Equal(t, tt.expectedPageSize, filter.PageSize, "PageSize mismatch") + }) + } +} + +func TestAccessLogFilter_GetOffset(t *testing.T) { + tests := []struct { + name string + page int + pageSize int + expectedOffset int + }{ + { + name: "first page", + page: 1, + pageSize: 50, + expectedOffset: 0, + }, + { + name: "second page", + page: 2, + pageSize: 50, + expectedOffset: 50, + }, + { + name: "third page with page size 25", + page: 3, + pageSize: 25, + expectedOffset: 50, + }, + { + name: "page 10 with page size 10", + page: 10, + pageSize: 10, + expectedOffset: 90, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filter := &AccessLogFilter{ + Page: tt.page, + PageSize: tt.pageSize, + } + + offset := filter.GetOffset() + assert.Equal(t, tt.expectedOffset, offset) + }) + } +} + +func TestAccessLogFilter_GetLimit(t *testing.T) { + filter := &AccessLogFilter{ + Page: 2, + PageSize: 25, + } + + limit := filter.GetLimit() + assert.Equal(t, 25, limit, "GetLimit should return PageSize") +} + +func TestAccessLogFilter_ParseFromRequest_FilterParams(t *testing.T) { + startDate := "2024-01-15T10:30:00Z" + endDate := "2024-01-16T15:45:00Z" + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + q := req.URL.Query() + q.Set("search", "test query") + q.Set("source_ip", "192.168.1.1") + q.Set("host", "example.com") + q.Set("path", "/api/users") + q.Set("user_id", "user123") + q.Set("user_email", "user@example.com") + q.Set("user_name", "John Doe") + q.Set("method", "GET") + q.Set("status", "success") + q.Set("status_code", "200") + q.Set("start_date", startDate) + q.Set("end_date", endDate) + req.URL.RawQuery = q.Encode() + + filter := &AccessLogFilter{} + filter.ParseFromRequest(req) + + require.NotNil(t, filter.Search) + assert.Equal(t, "test query", *filter.Search) + + require.NotNil(t, filter.SourceIP) + assert.Equal(t, "192.168.1.1", *filter.SourceIP) + + require.NotNil(t, filter.Host) + assert.Equal(t, "example.com", *filter.Host) + + require.NotNil(t, filter.Path) + assert.Equal(t, "/api/users", *filter.Path) + + require.NotNil(t, filter.UserID) + assert.Equal(t, "user123", *filter.UserID) + + require.NotNil(t, filter.UserEmail) + assert.Equal(t, "user@example.com", *filter.UserEmail) + + require.NotNil(t, filter.UserName) + assert.Equal(t, "John Doe", *filter.UserName) + + require.NotNil(t, filter.Method) + assert.Equal(t, "GET", *filter.Method) + + require.NotNil(t, filter.Status) + assert.Equal(t, "success", *filter.Status) + + require.NotNil(t, filter.StatusCode) + assert.Equal(t, 200, *filter.StatusCode) + + require.NotNil(t, filter.StartDate) + expectedStart, _ := time.Parse(time.RFC3339, startDate) + assert.Equal(t, expectedStart, *filter.StartDate) + + require.NotNil(t, filter.EndDate) + expectedEnd, _ := time.Parse(time.RFC3339, endDate) + assert.Equal(t, expectedEnd, *filter.EndDate) +} + +func TestAccessLogFilter_ParseFromRequest_EmptyFilters(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + + filter := &AccessLogFilter{} + filter.ParseFromRequest(req) + + assert.Nil(t, filter.Search) + assert.Nil(t, filter.SourceIP) + assert.Nil(t, filter.Host) + assert.Nil(t, filter.Path) + assert.Nil(t, filter.UserID) + assert.Nil(t, filter.UserEmail) + assert.Nil(t, filter.UserName) + assert.Nil(t, filter.Method) + assert.Nil(t, filter.Status) + assert.Nil(t, filter.StatusCode) + assert.Nil(t, filter.StartDate) + assert.Nil(t, filter.EndDate) +} + +func TestAccessLogFilter_ParseFromRequest_InvalidFilters(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + q := req.URL.Query() + q.Set("status_code", "invalid") + q.Set("start_date", "not-a-date") + q.Set("end_date", "2024-99-99") + req.URL.RawQuery = q.Encode() + + filter := &AccessLogFilter{} + filter.ParseFromRequest(req) + + assert.Nil(t, filter.StatusCode, "invalid status_code should be nil") + assert.Nil(t, filter.StartDate, "invalid start_date should be nil") + assert.Nil(t, filter.EndDate, "invalid end_date should be nil") +} + +func TestParsePositiveInt(t *testing.T) { + tests := []struct { + name string + input string + defaultValue int + expected int + }{ + {"empty string", "", 10, 10}, + {"valid positive int", "25", 10, 25}, + {"zero", "0", 10, 10}, + {"negative", "-5", 10, 10}, + {"invalid string", "abc", 10, 10}, + {"float", "3.14", 10, 10}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parsePositiveInt(tt.input, tt.defaultValue) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseOptionalString(t *testing.T) { + tests := []struct { + name string + input string + expected *string + }{ + {"empty string", "", nil}, + {"valid string", "hello", strPtr("hello")}, + {"whitespace", " ", strPtr(" ")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseOptionalString(tt.input) + if tt.expected == nil { + assert.Nil(t, result) + } else { + require.NotNil(t, result) + assert.Equal(t, *tt.expected, *result) + } + }) + } +} + +func TestParseOptionalInt(t *testing.T) { + tests := []struct { + name string + input string + expected *int + }{ + {"empty string", "", nil}, + {"valid positive int", "42", intPtr(42)}, + {"zero", "0", nil}, + {"negative", "-10", nil}, + {"invalid string", "abc", nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseOptionalInt(tt.input) + if tt.expected == nil { + assert.Nil(t, result) + } else { + require.NotNil(t, result) + assert.Equal(t, *tt.expected, *result) + } + }) + } +} + +func TestParseOptionalRFC3339(t *testing.T) { + validDate := "2024-01-15T10:30:00Z" + expectedTime, _ := time.Parse(time.RFC3339, validDate) + + tests := []struct { + name string + input string + expected *time.Time + }{ + {"empty string", "", nil}, + {"valid RFC3339", validDate, &expectedTime}, + {"invalid format", "2024-01-15", nil}, + {"invalid date", "not-a-date", nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseOptionalRFC3339(tt.input) + if tt.expected == nil { + assert.Nil(t, result) + } else { + require.NotNil(t, result) + assert.Equal(t, *tt.expected, *result) + } + }) + } +} + +func TestAccessLogFilter_SortingDefaults(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + + filter := &AccessLogFilter{} + filter.ParseFromRequest(req) + + assert.Equal(t, DefaultSortBy, filter.SortBy, "SortBy should default to timestamp") + assert.Equal(t, DefaultSortOrder, filter.SortOrder, "SortOrder should default to desc") + assert.Equal(t, "timestamp", filter.GetSortColumn(), "GetSortColumn should return timestamp") + assert.Equal(t, "desc", filter.GetSortOrder(), "GetSortOrder should return desc") +} + +func TestAccessLogFilter_ValidSortFields(t *testing.T) { + tests := []struct { + name string + sortBy string + expectedColumn string + expectedSortByVal string + }{ + {"timestamp", "timestamp", "timestamp", "timestamp"}, + {"url", "url", "host, path", "url"}, + {"host", "host", "host", "host"}, + {"path", "path", "path", "path"}, + {"method", "method", "method", "method"}, + {"status_code", "status_code", "status_code", "status_code"}, + {"duration", "duration", "duration", "duration"}, + {"source_ip", "source_ip", "location_connection_ip", "source_ip"}, + {"user_id", "user_id", "user_id", "user_id"}, + {"auth_method", "auth_method", "auth_method_used", "auth_method"}, + {"reason", "reason", "reason", "reason"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test?sort_by="+tt.sortBy, nil) + + filter := &AccessLogFilter{} + filter.ParseFromRequest(req) + + assert.Equal(t, tt.expectedSortByVal, filter.SortBy, "SortBy mismatch") + assert.Equal(t, tt.expectedColumn, filter.GetSortColumn(), "GetSortColumn mismatch") + }) + } +} + +func TestAccessLogFilter_InvalidSortField(t *testing.T) { + tests := []struct { + name string + sortBy string + expected string + }{ + {"invalid field", "invalid_field", DefaultSortBy}, + {"empty field", "", DefaultSortBy}, + {"malicious input", "timestamp--DROP", DefaultSortBy}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + q := req.URL.Query() + q.Set("sort_by", tt.sortBy) + req.URL.RawQuery = q.Encode() + + filter := &AccessLogFilter{} + filter.ParseFromRequest(req) + + assert.Equal(t, tt.expected, filter.SortBy, "Invalid sort field should default to timestamp") + assert.Equal(t, validSortFields[DefaultSortBy], filter.GetSortColumn()) + }) + } +} + +func TestAccessLogFilter_SortOrder(t *testing.T) { + tests := []struct { + name string + sortOrder string + expected string + }{ + {"ascending", "asc", "asc"}, + {"descending", "desc", "desc"}, + {"uppercase ASC", "ASC", "asc"}, + {"uppercase DESC", "DESC", "desc"}, + {"mixed case Asc", "Asc", "asc"}, + {"invalid order", "invalid", DefaultSortOrder}, + {"empty order", "", DefaultSortOrder}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test?sort_order="+tt.sortOrder, nil) + + filter := &AccessLogFilter{} + filter.ParseFromRequest(req) + + assert.Equal(t, tt.expected, filter.GetSortOrder(), "GetSortOrder mismatch") + }) + } +} + +func TestAccessLogFilter_CompleteSortingScenarios(t *testing.T) { + tests := []struct { + name string + sortBy string + sortOrder string + expectedColumn string + expectedOrder string + }{ + { + name: "sort by host ascending", + sortBy: "host", + sortOrder: "asc", + expectedColumn: "host", + expectedOrder: "asc", + }, + { + name: "sort by duration descending", + sortBy: "duration", + sortOrder: "desc", + expectedColumn: "duration", + expectedOrder: "desc", + }, + { + name: "sort by status_code ascending", + sortBy: "status_code", + sortOrder: "asc", + expectedColumn: "status_code", + expectedOrder: "asc", + }, + { + name: "invalid sort with valid order", + sortBy: "invalid", + sortOrder: "asc", + expectedColumn: "timestamp", + expectedOrder: "asc", + }, + { + name: "valid sort with invalid order", + sortBy: "method", + sortOrder: "invalid", + expectedColumn: "method", + expectedOrder: DefaultSortOrder, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test?sort_by="+tt.sortBy+"&sort_order="+tt.sortOrder, nil) + + filter := &AccessLogFilter{} + filter.ParseFromRequest(req) + + assert.Equal(t, tt.expectedColumn, filter.GetSortColumn()) + assert.Equal(t, tt.expectedOrder, filter.GetSortOrder()) + }) + } +} + +func TestParseSortField(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"valid field", "host", "host"}, + {"empty string", "", DefaultSortBy}, + {"invalid field", "invalid", DefaultSortBy}, + {"malicious input", "timestamp--DROP", DefaultSortBy}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseSortField(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseSortOrder(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"asc lowercase", "asc", "asc"}, + {"desc lowercase", "desc", "desc"}, + {"ASC uppercase", "ASC", "asc"}, + {"DESC uppercase", "DESC", "desc"}, + {"invalid", "invalid", DefaultSortOrder}, + {"empty", "", DefaultSortOrder}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseSortOrder(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +// Helper functions for creating pointers +func strPtr(s string) *string { + return &s +} + +func intPtr(i int) *int { + return &i +} diff --git a/management/internals/modules/reverseproxy/accesslogs/interface.go b/management/internals/modules/reverseproxy/accesslogs/interface.go new file mode 100644 index 000000000..04f096bf1 --- /dev/null +++ b/management/internals/modules/reverseproxy/accesslogs/interface.go @@ -0,0 +1,13 @@ +package accesslogs + +import ( + "context" +) + +type Manager interface { + SaveAccessLog(ctx context.Context, proxyLog *AccessLogEntry) error + GetAllAccessLogs(ctx context.Context, accountID, userID string, filter *AccessLogFilter) ([]*AccessLogEntry, int64, error) + CleanupOldAccessLogs(ctx context.Context, retentionDays int) (int64, error) + StartPeriodicCleanup(ctx context.Context, retentionDays, cleanupIntervalHours int) + StopPeriodicCleanup() +} diff --git a/management/internals/modules/reverseproxy/accesslogs/manager/api.go b/management/internals/modules/reverseproxy/accesslogs/manager/api.go new file mode 100644 index 000000000..1e1414ca5 --- /dev/null +++ b/management/internals/modules/reverseproxy/accesslogs/manager/api.go @@ -0,0 +1,64 @@ +package manager + +import ( + "net/http" + + "github.com/gorilla/mux" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" +) + +type handler struct { + manager accesslogs.Manager +} + +func RegisterEndpoints(router *mux.Router, manager accesslogs.Manager) { + h := &handler{ + manager: manager, + } + + router.HandleFunc("/events/proxy", h.getAccessLogs).Methods("GET", "OPTIONS") +} + +func (h *handler) getAccessLogs(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + var filter accesslogs.AccessLogFilter + filter.ParseFromRequest(r) + + logs, totalCount, err := h.manager.GetAllAccessLogs(r.Context(), userAuth.AccountId, userAuth.UserId, &filter) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + apiLogs := make([]api.ProxyAccessLog, 0, len(logs)) + for _, log := range logs { + apiLogs = append(apiLogs, *log.ToAPIResponse()) + } + + response := &api.ProxyAccessLogsResponse{ + Data: apiLogs, + Page: filter.Page, + PageSize: filter.PageSize, + TotalRecords: int(totalCount), + TotalPages: getTotalPageCount(int(totalCount), filter.PageSize), + } + + util.WriteJSONObject(r.Context(), w, response) +} + +// getTotalPageCount calculates the total number of pages +func getTotalPageCount(totalCount, pageSize int) int { + if pageSize <= 0 { + return 0 + } + return (totalCount + pageSize - 1) / pageSize +} diff --git a/management/internals/modules/reverseproxy/accesslogs/manager/manager.go b/management/internals/modules/reverseproxy/accesslogs/manager/manager.go new file mode 100644 index 000000000..e8d0ce763 --- /dev/null +++ b/management/internals/modules/reverseproxy/accesslogs/manager/manager.go @@ -0,0 +1,181 @@ +package manager + +import ( + "context" + "strings" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + "github.com/netbirdio/netbird/management/server/geolocation" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/permissions/modules" + "github.com/netbirdio/netbird/management/server/permissions/operations" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/shared/management/status" +) + +type managerImpl struct { + store store.Store + permissionsManager permissions.Manager + geo geolocation.Geolocation + cleanupCancel context.CancelFunc +} + +func NewManager(store store.Store, permissionsManager permissions.Manager, geo geolocation.Geolocation) accesslogs.Manager { + return &managerImpl{ + store: store, + permissionsManager: permissionsManager, + geo: geo, + } +} + +// SaveAccessLog saves an access log entry to the database after enriching it +func (m *managerImpl) SaveAccessLog(ctx context.Context, logEntry *accesslogs.AccessLogEntry) error { + if m.geo != nil && logEntry.GeoLocation.ConnectionIP != nil { + location, err := m.geo.Lookup(logEntry.GeoLocation.ConnectionIP) + if err != nil { + log.WithContext(ctx).Warnf("failed to get location for access log source IP [%s]: %v", logEntry.GeoLocation.ConnectionIP.String(), err) + } else { + logEntry.GeoLocation.CountryCode = location.Country.ISOCode + logEntry.GeoLocation.CityName = location.City.Names.En + logEntry.GeoLocation.GeoNameID = location.City.GeonameID + if len(location.Subdivisions) > 0 { + logEntry.SubdivisionCode = location.Subdivisions[0].ISOCode + } + } + } + + if err := m.store.CreateAccessLog(ctx, logEntry); err != nil { + log.WithContext(ctx).WithFields(log.Fields{ + "service_id": logEntry.ServiceID, + "method": logEntry.Method, + "host": logEntry.Host, + "path": logEntry.Path, + "status": logEntry.StatusCode, + }).Errorf("failed to save access log: %v", err) + return err + } + + return nil +} + +// GetAllAccessLogs retrieves access logs for an account with pagination and filtering +func (m *managerImpl) GetAllAccessLogs(ctx context.Context, accountID, userID string, filter *accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) { + ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read) + if err != nil { + return nil, 0, status.NewPermissionValidationError(err) + } + if !ok { + return nil, 0, status.NewPermissionDeniedError() + } + + if err := m.resolveUserFilters(ctx, accountID, filter); err != nil { + log.WithContext(ctx).Warnf("failed to resolve user filters: %v", err) + } + + logs, totalCount, err := m.store.GetAccountAccessLogs(ctx, store.LockingStrengthNone, accountID, *filter) + if err != nil { + return nil, 0, err + } + + return logs, totalCount, nil +} + +// CleanupOldAccessLogs deletes access logs older than the specified retention period +func (m *managerImpl) CleanupOldAccessLogs(ctx context.Context, retentionDays int) (int64, error) { + if retentionDays <= 0 { + log.WithContext(ctx).Debug("access log cleanup skipped: retention days is 0 or negative") + return 0, nil + } + + cutoffTime := time.Now().AddDate(0, 0, -retentionDays) + deletedCount, err := m.store.DeleteOldAccessLogs(ctx, cutoffTime) + if err != nil { + log.WithContext(ctx).Errorf("failed to cleanup old access logs: %v", err) + return 0, err + } + + if deletedCount > 0 { + log.WithContext(ctx).Infof("cleaned up %d access logs older than %d days", deletedCount, retentionDays) + } + + return deletedCount, nil +} + +// StartPeriodicCleanup starts a background goroutine that periodically cleans up old access logs +func (m *managerImpl) StartPeriodicCleanup(ctx context.Context, retentionDays, cleanupIntervalHours int) { + if retentionDays <= 0 { + log.WithContext(ctx).Debug("periodic access log cleanup disabled: retention days is 0 or negative") + return + } + + if cleanupIntervalHours <= 0 { + cleanupIntervalHours = 24 + } + + cleanupCtx, cancel := context.WithCancel(ctx) + m.cleanupCancel = cancel + + cleanupInterval := time.Duration(cleanupIntervalHours) * time.Hour + ticker := time.NewTicker(cleanupInterval) + + go func() { + defer ticker.Stop() + + // Run cleanup immediately on startup + log.WithContext(cleanupCtx).Infof("starting access log cleanup routine (retention: %d days, interval: %d hours)", retentionDays, cleanupIntervalHours) + if _, err := m.CleanupOldAccessLogs(cleanupCtx, retentionDays); err != nil { + log.WithContext(cleanupCtx).Errorf("initial access log cleanup failed: %v", err) + } + + for { + select { + case <-cleanupCtx.Done(): + log.WithContext(cleanupCtx).Info("stopping access log cleanup routine") + return + case <-ticker.C: + if _, err := m.CleanupOldAccessLogs(cleanupCtx, retentionDays); err != nil { + log.WithContext(cleanupCtx).Errorf("periodic access log cleanup failed: %v", err) + } + } + } + }() +} + +// StopPeriodicCleanup stops the periodic cleanup routine +func (m *managerImpl) StopPeriodicCleanup() { + if m.cleanupCancel != nil { + m.cleanupCancel() + } +} + +// resolveUserFilters converts user email/name filters to user ID filter +func (m *managerImpl) resolveUserFilters(ctx context.Context, accountID string, filter *accesslogs.AccessLogFilter) error { + if filter.UserEmail == nil && filter.UserName == nil { + return nil + } + + users, err := m.store.GetAccountUsers(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return err + } + + var matchingUserIDs []string + for _, user := range users { + if filter.UserEmail != nil && strings.Contains(strings.ToLower(user.Email), strings.ToLower(*filter.UserEmail)) { + matchingUserIDs = append(matchingUserIDs, user.Id) + continue + } + if filter.UserName != nil && strings.Contains(strings.ToLower(user.Name), strings.ToLower(*filter.UserName)) { + matchingUserIDs = append(matchingUserIDs, user.Id) + } + } + + if len(matchingUserIDs) > 0 { + filter.UserID = &matchingUserIDs[0] + } + + return nil +} diff --git a/management/internals/modules/reverseproxy/accesslogs/manager/manager_test.go b/management/internals/modules/reverseproxy/accesslogs/manager/manager_test.go new file mode 100644 index 000000000..8fadef85f --- /dev/null +++ b/management/internals/modules/reverseproxy/accesslogs/manager/manager_test.go @@ -0,0 +1,281 @@ +package manager + +import ( + "context" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/server/store" +) + +func TestCleanupOldAccessLogs(t *testing.T) { + tests := []struct { + name string + retentionDays int + setupMock func(*store.MockStore) + expectedCount int64 + expectedError bool + }{ + { + name: "cleanup logs older than retention period", + retentionDays: 30, + setupMock: func(mockStore *store.MockStore) { + mockStore.EXPECT(). + DeleteOldAccessLogs(gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, olderThan time.Time) (int64, error) { + expectedCutoff := time.Now().AddDate(0, 0, -30) + timeDiff := olderThan.Sub(expectedCutoff) + if timeDiff.Abs() > time.Second { + t.Errorf("cutoff time not as expected: got %v, want ~%v", olderThan, expectedCutoff) + } + return 5, nil + }) + }, + expectedCount: 5, + expectedError: false, + }, + { + name: "no logs to cleanup", + retentionDays: 30, + setupMock: func(mockStore *store.MockStore) { + mockStore.EXPECT(). + DeleteOldAccessLogs(gomock.Any(), gomock.Any()). + Return(int64(0), nil) + }, + expectedCount: 0, + expectedError: false, + }, + { + name: "zero retention days skips cleanup", + retentionDays: 0, + setupMock: func(mockStore *store.MockStore) { + // No expectations - DeleteOldAccessLogs should not be called + }, + expectedCount: 0, + expectedError: false, + }, + { + name: "negative retention days skips cleanup", + retentionDays: -10, + setupMock: func(mockStore *store.MockStore) { + // No expectations - DeleteOldAccessLogs should not be called + }, + expectedCount: 0, + expectedError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + tt.setupMock(mockStore) + + manager := &managerImpl{ + store: mockStore, + } + + ctx := context.Background() + deletedCount, err := manager.CleanupOldAccessLogs(ctx, tt.retentionDays) + + if tt.expectedError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, tt.expectedCount, deletedCount, "unexpected number of deleted logs") + }) + } +} + +func TestCleanupWithExactBoundary(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + + mockStore.EXPECT(). + DeleteOldAccessLogs(gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, olderThan time.Time) (int64, error) { + expectedCutoff := time.Now().AddDate(0, 0, -30) + timeDiff := olderThan.Sub(expectedCutoff) + assert.Less(t, timeDiff.Abs(), time.Second, "cutoff time should be close to expected value") + return 1, nil + }) + + manager := &managerImpl{ + store: mockStore, + } + + ctx := context.Background() + deletedCount, err := manager.CleanupOldAccessLogs(ctx, 30) + + require.NoError(t, err) + assert.Equal(t, int64(1), deletedCount) +} + +func TestStartPeriodicCleanup(t *testing.T) { + t.Run("periodic cleanup disabled with zero retention", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + // No expectations - cleanup should not run + + manager := &managerImpl{ + store: mockStore, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + manager.StartPeriodicCleanup(ctx, 0, 1) + + time.Sleep(100 * time.Millisecond) + + // If DeleteOldAccessLogs was called, the test will fail due to unexpected call + }) + + t.Run("periodic cleanup runs immediately on start", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + + mockStore.EXPECT(). + DeleteOldAccessLogs(gomock.Any(), gomock.Any()). + Return(int64(2), nil). + Times(1) + + manager := &managerImpl{ + store: mockStore, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + manager.StartPeriodicCleanup(ctx, 30, 24) + + time.Sleep(200 * time.Millisecond) + + // Expectations verified by gomock on defer ctrl.Finish() + }) + + t.Run("periodic cleanup stops on context cancel", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + + mockStore.EXPECT(). + DeleteOldAccessLogs(gomock.Any(), gomock.Any()). + Return(int64(1), nil). + Times(1) + + manager := &managerImpl{ + store: mockStore, + } + + ctx, cancel := context.WithCancel(context.Background()) + + manager.StartPeriodicCleanup(ctx, 30, 24) + + time.Sleep(100 * time.Millisecond) + + cancel() + + time.Sleep(200 * time.Millisecond) + + }) + + t.Run("cleanup interval defaults to 24 hours when invalid", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + + mockStore.EXPECT(). + DeleteOldAccessLogs(gomock.Any(), gomock.Any()). + Return(int64(0), nil). + Times(1) + + manager := &managerImpl{ + store: mockStore, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + manager.StartPeriodicCleanup(ctx, 30, 0) + + time.Sleep(100 * time.Millisecond) + + manager.StopPeriodicCleanup() + }) + + t.Run("cleanup interval uses configured hours", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + + mockStore.EXPECT(). + DeleteOldAccessLogs(gomock.Any(), gomock.Any()). + Return(int64(3), nil). + Times(1) + + manager := &managerImpl{ + store: mockStore, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + manager.StartPeriodicCleanup(ctx, 30, 12) + + time.Sleep(100 * time.Millisecond) + + manager.StopPeriodicCleanup() + }) +} + +func TestStopPeriodicCleanup(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + + mockStore.EXPECT(). + DeleteOldAccessLogs(gomock.Any(), gomock.Any()). + Return(int64(1), nil). + Times(1) + + manager := &managerImpl{ + store: mockStore, + } + + ctx := context.Background() + + manager.StartPeriodicCleanup(ctx, 30, 24) + + time.Sleep(100 * time.Millisecond) + + manager.StopPeriodicCleanup() + + time.Sleep(200 * time.Millisecond) + + // Expectations verified by gomock - would fail if more than 1 call happened +} + +func TestStopPeriodicCleanup_NotStarted(t *testing.T) { + manager := &managerImpl{} + + // Should not panic if cleanup was never started + manager.StopPeriodicCleanup() +} diff --git a/management/internals/modules/reverseproxy/domain/domain.go b/management/internals/modules/reverseproxy/domain/domain.go new file mode 100644 index 000000000..861d026a7 --- /dev/null +++ b/management/internals/modules/reverseproxy/domain/domain.go @@ -0,0 +1,29 @@ +package domain + +type Type string + +const ( + TypeFree Type = "free" + TypeCustom Type = "custom" +) + +type Domain struct { + ID string `gorm:"unique;primaryKey;autoIncrement"` + Domain string `gorm:"unique"` // Domain records must be unique, this avoids domain reuse across accounts. + AccountID string `gorm:"index"` + TargetCluster string // The proxy cluster this domain should be validated against + Type Type `gorm:"-"` + Validated bool + // SupportsCustomPorts is populated at query time for free domains from the + // proxy cluster capabilities. Not persisted. + SupportsCustomPorts *bool `gorm:"-"` +} + +// EventMeta returns activity event metadata for a domain +func (d *Domain) EventMeta() map[string]any { + return map[string]any{ + "domain": d.Domain, + "target_cluster": d.TargetCluster, + "validated": d.Validated, + } +} diff --git a/management/internals/modules/reverseproxy/domain/interface.go b/management/internals/modules/reverseproxy/domain/interface.go new file mode 100644 index 000000000..a4bba5841 --- /dev/null +++ b/management/internals/modules/reverseproxy/domain/interface.go @@ -0,0 +1,13 @@ +package domain + +import ( + "context" +) + +type Manager interface { + GetDomains(ctx context.Context, accountID, userID string) ([]*Domain, error) + CreateDomain(ctx context.Context, accountID, userID, domainName, targetCluster string) (*Domain, error) + DeleteDomain(ctx context.Context, accountID, userID, domainID string) error + ValidateDomain(ctx context.Context, accountID, userID, domainID string) + GetClusterDomains() []string +} diff --git a/management/internals/modules/reverseproxy/domain/manager/api.go b/management/internals/modules/reverseproxy/domain/manager/api.go new file mode 100644 index 000000000..d26a6a418 --- /dev/null +++ b/management/internals/modules/reverseproxy/domain/manager/api.go @@ -0,0 +1,137 @@ +package manager + +import ( + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" + "github.com/netbirdio/netbird/shared/management/status" +) + +type handler struct { + manager Manager +} + +func RegisterEndpoints(router *mux.Router, manager Manager) { + h := &handler{ + manager: manager, + } + + router.HandleFunc("/domains", h.getAllDomains).Methods("GET", "OPTIONS") + router.HandleFunc("/domains", h.createCustomDomain).Methods("POST", "OPTIONS") + router.HandleFunc("/domains/{domainId}", h.deleteCustomDomain).Methods("DELETE", "OPTIONS") + router.HandleFunc("/domains/{domainId}/validate", h.triggerCustomDomainValidation).Methods("GET", "OPTIONS") +} + +func domainTypeToApi(t domain.Type) api.ReverseProxyDomainType { + switch t { + case domain.TypeCustom: + return api.ReverseProxyDomainTypeCustom + case domain.TypeFree: + return api.ReverseProxyDomainTypeFree + } + // By default return as a "free" domain as that is more restrictive. + // TODO: is this correct? + return api.ReverseProxyDomainTypeFree +} + +func domainToApi(d *domain.Domain) api.ReverseProxyDomain { + resp := api.ReverseProxyDomain{ + Domain: d.Domain, + Id: d.ID, + Type: domainTypeToApi(d.Type), + Validated: d.Validated, + SupportsCustomPorts: d.SupportsCustomPorts, + } + if d.TargetCluster != "" { + resp.TargetCluster = &d.TargetCluster + } + return resp +} + +func (h *handler) getAllDomains(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + domains, err := h.manager.GetDomains(r.Context(), userAuth.AccountId, userAuth.UserId) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + ret := make([]api.ReverseProxyDomain, 0) + for _, d := range domains { + ret = append(ret, domainToApi(d)) + } + + util.WriteJSONObject(r.Context(), w, ret) +} + +func (h *handler) createCustomDomain(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + var req api.PostApiReverseProxiesDomainsJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + domain, err := h.manager.CreateDomain(r.Context(), userAuth.AccountId, userAuth.UserId, req.Domain, req.TargetCluster) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, domainToApi(domain)) +} + +func (h *handler) deleteCustomDomain(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + domainID := mux.Vars(r)["domainId"] + if domainID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "domain ID is required"), w) + return + } + + if err := h.manager.DeleteDomain(r.Context(), userAuth.AccountId, userAuth.UserId, domainID); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *handler) triggerCustomDomainValidation(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + domainID := mux.Vars(r)["domainId"] + if domainID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "domain ID is required"), w) + return + } + + go h.manager.ValidateDomain(r.Context(), userAuth.AccountId, userAuth.UserId, domainID) + + w.WriteHeader(http.StatusAccepted) +} diff --git a/management/internals/modules/reverseproxy/domain/manager/manager.go b/management/internals/modules/reverseproxy/domain/manager/manager.go new file mode 100644 index 000000000..813027ea2 --- /dev/null +++ b/management/internals/modules/reverseproxy/domain/manager/manager.go @@ -0,0 +1,324 @@ +package manager + +import ( + "context" + "fmt" + "net" + "strings" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" + "github.com/netbirdio/netbird/management/server/account" + "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/permissions/modules" + "github.com/netbirdio/netbird/management/server/permissions/operations" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +type store interface { + GetAccount(ctx context.Context, accountID string) (*types.Account, error) + + GetCustomDomain(ctx context.Context, accountID string, domainID string) (*domain.Domain, error) + ListFreeDomains(ctx context.Context, accountID string) ([]string, error) + ListCustomDomains(ctx context.Context, accountID string) ([]*domain.Domain, error) + CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool) (*domain.Domain, error) + UpdateCustomDomain(ctx context.Context, accountID string, d *domain.Domain) (*domain.Domain, error) + DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error +} + +type proxyManager interface { + GetActiveClusterAddresses(ctx context.Context) ([]string, error) +} + +type clusterCapabilities interface { + ClusterSupportsCustomPorts(clusterAddr string) *bool +} + +type Manager struct { + store store + validator domain.Validator + proxyManager proxyManager + clusterCapabilities clusterCapabilities + permissionsManager permissions.Manager + accountManager account.Manager +} + +func NewManager(store store, proxyMgr proxyManager, permissionsManager permissions.Manager, accountManager account.Manager) Manager { + return Manager{ + store: store, + proxyManager: proxyMgr, + validator: domain.Validator{Resolver: net.DefaultResolver}, + permissionsManager: permissionsManager, + accountManager: accountManager, + } +} + +// SetClusterCapabilities sets the cluster capabilities provider for domain queries. +func (m *Manager) SetClusterCapabilities(caps clusterCapabilities) { + m.clusterCapabilities = caps +} + +func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*domain.Domain, error) { + ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return nil, status.NewPermissionDeniedError() + } + + domains, err := m.store.ListCustomDomains(ctx, accountID) + if err != nil { + return nil, fmt.Errorf("list custom domains: %w", err) + } + + var ret []*domain.Domain + + // Add connected proxy clusters as free domains. + // The cluster address itself is the free domain base (e.g., "eu.proxy.netbird.io"). + allowList, err := m.proxyManager.GetActiveClusterAddresses(ctx) + if err != nil { + log.WithContext(ctx).Errorf("failed to get active proxy cluster addresses: %v", err) + return nil, err + } + log.WithContext(ctx).WithFields(log.Fields{ + "accountID": accountID, + "proxyAllowList": allowList, + }).Debug("getting domains with proxy allow list") + + for _, cluster := range allowList { + d := &domain.Domain{ + Domain: cluster, + AccountID: accountID, + Type: domain.TypeFree, + Validated: true, + } + if m.clusterCapabilities != nil { + d.SupportsCustomPorts = m.clusterCapabilities.ClusterSupportsCustomPorts(cluster) + } + ret = append(ret, d) + } + + // Add custom domains. + for _, d := range domains { + cd := &domain.Domain{ + ID: d.ID, + Domain: d.Domain, + AccountID: accountID, + TargetCluster: d.TargetCluster, + Type: domain.TypeCustom, + Validated: d.Validated, + } + if m.clusterCapabilities != nil && d.TargetCluster != "" { + cd.SupportsCustomPorts = m.clusterCapabilities.ClusterSupportsCustomPorts(d.TargetCluster) + } + ret = append(ret, cd) + } + + return ret, nil +} + +func (m Manager) CreateDomain(ctx context.Context, accountID, userID, domainName, targetCluster string) (*domain.Domain, error) { + ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return nil, status.NewPermissionDeniedError() + } + + // Verify the target cluster is in the available clusters + allowList, err := m.proxyManager.GetActiveClusterAddresses(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get active proxy cluster addresses: %w", err) + } + clusterValid := false + for _, cluster := range allowList { + if cluster == targetCluster { + clusterValid = true + break + } + } + if !clusterValid { + return nil, fmt.Errorf("target cluster %s is not available", targetCluster) + } + + // Attempt an initial validation against the specified cluster only + var validated bool + if m.validator.IsValid(ctx, domainName, []string{targetCluster}) { + validated = true + } + + d, err := m.store.CreateCustomDomain(ctx, accountID, domainName, targetCluster, validated) + if err != nil { + return d, fmt.Errorf("create domain in store: %w", err) + } + + m.accountManager.StoreEvent(ctx, userID, d.ID, accountID, activity.DomainAdded, d.EventMeta()) + + return d, nil +} + +func (m Manager) DeleteDomain(ctx context.Context, accountID, userID, domainID string) error { + ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete) + if err != nil { + return status.NewPermissionValidationError(err) + } + if !ok { + return status.NewPermissionDeniedError() + } + + d, err := m.store.GetCustomDomain(ctx, accountID, domainID) + if err != nil { + return fmt.Errorf("get domain from store: %w", err) + } + + if err := m.store.DeleteCustomDomain(ctx, accountID, domainID); err != nil { + // TODO: check for "no records" type error. Because that is a success condition. + return fmt.Errorf("delete domain from store: %w", err) + } + + m.accountManager.StoreEvent(ctx, userID, domainID, accountID, activity.DomainDeleted, d.EventMeta()) + + return nil +} + +func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID string) { + ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create) + if err != nil { + log.WithFields(log.Fields{ + "accountID": accountID, + "domainID": domainID, + }).WithError(err).Error("validate domain") + return + } + if !ok { + log.WithFields(log.Fields{ + "accountID": accountID, + "domainID": domainID, + }).WithError(err).Error("validate domain") + } + + log.WithFields(log.Fields{ + "accountID": accountID, + "domainID": domainID, + }).Info("starting domain validation") + + d, err := m.store.GetCustomDomain(context.Background(), accountID, domainID) + if err != nil { + log.WithFields(log.Fields{ + "accountID": accountID, + "domainID": domainID, + }).WithError(err).Error("get custom domain from store") + return + } + + // Validate only against the domain's target cluster + targetCluster := d.TargetCluster + if targetCluster == "" { + log.WithFields(log.Fields{ + "accountID": accountID, + "domainID": domainID, + "domain": d.Domain, + }).Warn("domain has no target cluster set, skipping validation") + return + } + + log.WithFields(log.Fields{ + "accountID": accountID, + "domainID": domainID, + "domain": d.Domain, + "targetCluster": targetCluster, + }).Info("validating domain against target cluster") + + if m.validator.IsValid(context.Background(), d.Domain, []string{targetCluster}) { + log.WithFields(log.Fields{ + "accountID": accountID, + "domainID": domainID, + "domain": d.Domain, + }).Info("domain validated successfully") + d.Validated = true + if _, err := m.store.UpdateCustomDomain(context.Background(), accountID, d); err != nil { + log.WithFields(log.Fields{ + "accountID": accountID, + "domainID": domainID, + "domain": d.Domain, + }).WithError(err).Error("update custom domain in store") + return + } + + m.accountManager.StoreEvent(context.Background(), userID, domainID, accountID, activity.DomainValidated, d.EventMeta()) + } else { + log.WithFields(log.Fields{ + "accountID": accountID, + "domainID": domainID, + "domain": d.Domain, + "targetCluster": targetCluster, + }).Warn("domain validation failed - CNAME does not match target cluster") + } +} + +// GetClusterDomains returns a list of proxy cluster domains. +func (m Manager) GetClusterDomains() []string { + if m.proxyManager == nil { + return nil + } + addresses, err := m.proxyManager.GetActiveClusterAddresses(context.Background()) + if err != nil { + return nil + } + return addresses +} + +// DeriveClusterFromDomain determines the proxy cluster for a given domain. +// For free domains (those ending with a known cluster suffix), the cluster is extracted from the domain. +// For custom domains, the cluster is determined by checking the registered custom domain's target cluster. +func (m Manager) DeriveClusterFromDomain(ctx context.Context, accountID, domain string) (string, error) { + allowList, err := m.proxyManager.GetActiveClusterAddresses(ctx) + if err != nil { + return "", fmt.Errorf("failed to get active proxy cluster addresses: %w", err) + } + if len(allowList) == 0 { + return "", fmt.Errorf("no proxy clusters available") + } + + if cluster, ok := ExtractClusterFromFreeDomain(domain, allowList); ok { + return cluster, nil + } + + customDomains, err := m.store.ListCustomDomains(ctx, accountID) + if err != nil { + return "", fmt.Errorf("list custom domains: %w", err) + } + + targetCluster, valid := extractClusterFromCustomDomains(domain, customDomains) + if valid { + return targetCluster, nil + } + + return "", fmt.Errorf("domain %s does not match any available proxy cluster", domain) +} + +func extractClusterFromCustomDomains(domain string, customDomains []*domain.Domain) (string, bool) { + for _, customDomain := range customDomains { + if strings.HasSuffix(domain, "."+customDomain.Domain) { + return customDomain.TargetCluster, true + } + } + return "", false +} + +// ExtractClusterFromFreeDomain extracts the cluster address from a free domain. +// Free domains have the format: .. (e.g., myapp.abc123.eu.proxy.netbird.io) +// It matches the domain suffix against available clusters and returns the matching cluster. +func ExtractClusterFromFreeDomain(domain string, availableClusters []string) (string, bool) { + for _, cluster := range availableClusters { + if domain == cluster || strings.HasSuffix(domain, "."+cluster) { + return cluster, true + } + } + return "", false +} diff --git a/management/internals/modules/reverseproxy/domain/validator.go b/management/internals/modules/reverseproxy/domain/validator.go new file mode 100644 index 000000000..9c23c1192 --- /dev/null +++ b/management/internals/modules/reverseproxy/domain/validator.go @@ -0,0 +1,88 @@ +package domain + +import ( + "context" + "net" + "strings" + + log "github.com/sirupsen/logrus" +) + +type resolver interface { + LookupCNAME(context.Context, string) (string, error) +} + +type Validator struct { + Resolver resolver +} + +// NewValidator initializes a validator with a specific DNS Resolver. +// If a Validator is used without specifying a Resolver, then it will +// use the net.DefaultResolver. +func NewValidator(resolver resolver) *Validator { + return &Validator{ + Resolver: resolver, + } +} + +// IsValid looks up the CNAME record for the passed domain with a prefix +// and compares it against the acceptable domains. +// If the returned CNAME matches any accepted domain, it will return true, +// otherwise, including in the event of a DNS error, it will return false. +// The comparison is very simple, so wildcards will not match if included +// in the acceptable domain list. +func (v *Validator) IsValid(ctx context.Context, domain string, accept []string) bool { + _, valid := v.ValidateWithCluster(ctx, domain, accept) + return valid +} + +// ValidateWithCluster validates a custom domain and returns the matched cluster address. +// Returns the cluster address and true if valid, or empty string and false if invalid. +func (v *Validator) ValidateWithCluster(ctx context.Context, domain string, accept []string) (string, bool) { + if v.Resolver == nil { + v.Resolver = net.DefaultResolver + } + + lookupDomain := "validation." + domain + log.WithFields(log.Fields{ + "domain": domain, + "lookupDomain": lookupDomain, + "acceptList": accept, + }).Debug("looking up CNAME for domain validation") + + cname, err := v.Resolver.LookupCNAME(ctx, lookupDomain) + if err != nil { + log.WithFields(log.Fields{ + "domain": domain, + "lookupDomain": lookupDomain, + }).WithError(err).Warn("CNAME lookup failed for domain validation") + return "", false + } + + nakedCNAME := strings.TrimSuffix(cname, ".") + log.WithFields(log.Fields{ + "domain": domain, + "cname": cname, + "nakedCNAME": nakedCNAME, + "acceptList": accept, + }).Debug("CNAME lookup result for domain validation") + + for _, acceptDomain := range accept { + normalizedAccept := strings.TrimSuffix(acceptDomain, ".") + if nakedCNAME == normalizedAccept { + log.WithFields(log.Fields{ + "domain": domain, + "cname": nakedCNAME, + "cluster": acceptDomain, + }).Info("domain CNAME matched cluster") + return acceptDomain, true + } + } + + log.WithFields(log.Fields{ + "domain": domain, + "cname": nakedCNAME, + "acceptList": accept, + }).Warn("domain CNAME does not match any accepted cluster") + return "", false +} diff --git a/management/internals/modules/reverseproxy/domain/validator_test.go b/management/internals/modules/reverseproxy/domain/validator_test.go new file mode 100644 index 000000000..1f9583728 --- /dev/null +++ b/management/internals/modules/reverseproxy/domain/validator_test.go @@ -0,0 +1,56 @@ +package domain_test + +import ( + "context" + "testing" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" +) + +type resolver struct { + CNAME string +} + +func (r resolver) LookupCNAME(_ context.Context, _ string) (string, error) { + return r.CNAME, nil +} + +func TestIsValid(t *testing.T) { + tests := map[string]struct { + resolver interface { + LookupCNAME(context.Context, string) (string, error) + } + domain string + accept []string + expect bool + }{ + "match": { + resolver: resolver{"bar.example.com."}, // Including trailing "." in response. + domain: "foo.example.com", + accept: []string{"bar.example.com"}, + expect: true, + }, + "no match": { + resolver: resolver{"invalid"}, + domain: "foo.example.com", + accept: []string{"bar.example.com"}, + expect: false, + }, + "accept trailing dot": { + resolver: resolver{"bar.example.com."}, + domain: "foo.example.com", + accept: []string{"bar.example.com."}, // Including trailing "." in accept. + expect: true, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + validator := domain.NewValidator(test.resolver) + actual := validator.IsValid(t.Context(), test.domain, test.accept) + if test.expect != actual { + t.Errorf("Incorrect return value:\nexpect: %v\nactual: %v", test.expect, actual) + } + }) + } +} diff --git a/management/internals/modules/reverseproxy/proxy/manager.go b/management/internals/modules/reverseproxy/proxy/manager.go new file mode 100644 index 000000000..67a8e74fa --- /dev/null +++ b/management/internals/modules/reverseproxy/proxy/manager.go @@ -0,0 +1,37 @@ +package proxy + +//go:generate go run github.com/golang/mock/mockgen -package proxy -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod + +import ( + "context" + "time" + + "github.com/netbirdio/netbird/shared/management/proto" +) + +// Manager defines the interface for proxy operations +type Manager interface { + Connect(ctx context.Context, proxyID, clusterAddress, ipAddress string) error + Disconnect(ctx context.Context, proxyID string) error + Heartbeat(ctx context.Context, proxyID string) error + GetActiveClusterAddresses(ctx context.Context) ([]string, error) + CleanupStale(ctx context.Context, inactivityDuration time.Duration) error +} + +// OIDCValidationConfig contains the OIDC configuration needed for token validation. +type OIDCValidationConfig struct { + Issuer string + Audiences []string + KeysLocation string + MaxTokenAgeSeconds int64 +} + +// Controller is responsible for managing proxy clusters and routing service updates. +type Controller interface { + SendServiceUpdateToCluster(ctx context.Context, accountID string, update *proto.ProxyMapping, clusterAddr string) + GetOIDCValidationConfig() OIDCValidationConfig + RegisterProxyToCluster(ctx context.Context, clusterAddr, proxyID string) error + UnregisterProxyFromCluster(ctx context.Context, clusterAddr, proxyID string) error + GetProxiesForCluster(clusterAddr string) []string + ClusterSupportsCustomPorts(clusterAddr string) *bool +} diff --git a/management/internals/modules/reverseproxy/proxy/manager/controller.go b/management/internals/modules/reverseproxy/proxy/manager/controller.go new file mode 100644 index 000000000..acb49c45b --- /dev/null +++ b/management/internals/modules/reverseproxy/proxy/manager/controller.go @@ -0,0 +1,93 @@ +package manager + +import ( + "context" + "sync" + + log "github.com/sirupsen/logrus" + "go.opentelemetry.io/otel/metric" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// GRPCController is a concrete implementation that manages proxy clusters and sends updates directly via gRPC. +type GRPCController struct { + proxyGRPCServer *nbgrpc.ProxyServiceServer + // Map of cluster address -> set of proxy IDs + clusterProxies sync.Map + metrics *metrics +} + +// NewGRPCController creates a new GRPCController. +func NewGRPCController(proxyGRPCServer *nbgrpc.ProxyServiceServer, meter metric.Meter) (*GRPCController, error) { + m, err := newMetrics(meter) + if err != nil { + return nil, err + } + + return &GRPCController{ + proxyGRPCServer: proxyGRPCServer, + metrics: m, + }, nil +} + +// SendServiceUpdateToCluster sends a service update to a specific proxy cluster. +func (c *GRPCController) SendServiceUpdateToCluster(ctx context.Context, accountID string, update *proto.ProxyMapping, clusterAddr string) { + c.proxyGRPCServer.SendServiceUpdateToCluster(ctx, update, clusterAddr) + c.metrics.IncrementServiceUpdateSendCount(clusterAddr) +} + +// GetOIDCValidationConfig returns the OIDC validation configuration from the gRPC server. +func (c *GRPCController) GetOIDCValidationConfig() proxy.OIDCValidationConfig { + return c.proxyGRPCServer.GetOIDCValidationConfig() +} + +// RegisterProxyToCluster registers a proxy to a specific cluster for routing. +func (c *GRPCController) RegisterProxyToCluster(ctx context.Context, clusterAddr, proxyID string) error { + if clusterAddr == "" { + return nil + } + proxySet, _ := c.clusterProxies.LoadOrStore(clusterAddr, &sync.Map{}) + proxySet.(*sync.Map).Store(proxyID, struct{}{}) + log.WithContext(ctx).Debugf("Registered proxy %s to cluster %s", proxyID, clusterAddr) + + c.metrics.IncrementProxyConnectionCount(clusterAddr) + + return nil +} + +// UnregisterProxyFromCluster removes a proxy from a cluster. +func (c *GRPCController) UnregisterProxyFromCluster(ctx context.Context, clusterAddr, proxyID string) error { + if clusterAddr == "" { + return nil + } + if proxySet, ok := c.clusterProxies.Load(clusterAddr); ok { + proxySet.(*sync.Map).Delete(proxyID) + log.WithContext(ctx).Debugf("Unregistered proxy %s from cluster %s", proxyID, clusterAddr) + + c.metrics.DecrementProxyConnectionCount(clusterAddr) + } + return nil +} + +// ClusterSupportsCustomPorts returns whether any proxy in the cluster supports custom ports. +func (c *GRPCController) ClusterSupportsCustomPorts(clusterAddr string) *bool { + return c.proxyGRPCServer.ClusterSupportsCustomPorts(clusterAddr) +} + +// GetProxiesForCluster returns all proxy IDs registered for a specific cluster. +func (c *GRPCController) GetProxiesForCluster(clusterAddr string) []string { + proxySet, ok := c.clusterProxies.Load(clusterAddr) + if !ok { + return nil + } + + var proxies []string + proxySet.(*sync.Map).Range(func(key, _ interface{}) bool { + proxies = append(proxies, key.(string)) + return true + }) + return proxies +} diff --git a/management/internals/modules/reverseproxy/proxy/manager/manager.go b/management/internals/modules/reverseproxy/proxy/manager/manager.go new file mode 100644 index 000000000..4c0964b5c --- /dev/null +++ b/management/internals/modules/reverseproxy/proxy/manager/manager.go @@ -0,0 +1,115 @@ +package manager + +import ( + "context" + "time" + + log "github.com/sirupsen/logrus" + "go.opentelemetry.io/otel/metric" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" +) + +// store defines the interface for proxy persistence operations +type store interface { + SaveProxy(ctx context.Context, p *proxy.Proxy) error + UpdateProxyHeartbeat(ctx context.Context, proxyID string) error + GetActiveProxyClusterAddresses(ctx context.Context) ([]string, error) + CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error +} + +// Manager handles all proxy operations +type Manager struct { + store store + metrics *metrics +} + +// NewManager creates a new proxy Manager +func NewManager(store store, meter metric.Meter) (*Manager, error) { + m, err := newMetrics(meter) + if err != nil { + return nil, err + } + + return &Manager{ + store: store, + metrics: m, + }, nil +} + +// Connect registers a new proxy connection in the database +func (m Manager) Connect(ctx context.Context, proxyID, clusterAddress, ipAddress string) error { + now := time.Now() + p := &proxy.Proxy{ + ID: proxyID, + ClusterAddress: clusterAddress, + IPAddress: ipAddress, + LastSeen: now, + ConnectedAt: &now, + Status: "connected", + } + + if err := m.store.SaveProxy(ctx, p); err != nil { + log.WithContext(ctx).Errorf("failed to register proxy %s: %v", proxyID, err) + return err + } + + log.WithContext(ctx).WithFields(log.Fields{ + "proxyID": proxyID, + "clusterAddress": clusterAddress, + "ipAddress": ipAddress, + }).Info("proxy connected") + + return nil +} + +// Disconnect marks a proxy as disconnected in the database +func (m Manager) Disconnect(ctx context.Context, proxyID string) error { + now := time.Now() + p := &proxy.Proxy{ + ID: proxyID, + Status: "disconnected", + DisconnectedAt: &now, + LastSeen: now, + } + + if err := m.store.SaveProxy(ctx, p); err != nil { + log.WithContext(ctx).Errorf("failed to disconnect proxy %s: %v", proxyID, err) + return err + } + + log.WithContext(ctx).WithFields(log.Fields{ + "proxyID": proxyID, + }).Info("proxy disconnected") + + return nil +} + +// Heartbeat updates the proxy's last seen timestamp +func (m Manager) Heartbeat(ctx context.Context, proxyID string) error { + if err := m.store.UpdateProxyHeartbeat(ctx, proxyID); err != nil { + log.WithContext(ctx).Debugf("failed to update proxy %s heartbeat: %v", proxyID, err) + return err + } + m.metrics.IncrementProxyHeartbeatCount() + return nil +} + +// GetActiveClusterAddresses returns all unique cluster addresses for active proxies +func (m Manager) GetActiveClusterAddresses(ctx context.Context) ([]string, error) { + addresses, err := m.store.GetActiveProxyClusterAddresses(ctx) + if err != nil { + log.WithContext(ctx).Errorf("failed to get active proxy cluster addresses: %v", err) + return nil, err + } + return addresses, nil +} + +// CleanupStale removes proxies that haven't sent heartbeat in the specified duration +func (m Manager) CleanupStale(ctx context.Context, inactivityDuration time.Duration) error { + if err := m.store.CleanupStaleProxies(ctx, inactivityDuration); err != nil { + log.WithContext(ctx).Errorf("failed to cleanup stale proxies: %v", err) + return err + } + return nil +} diff --git a/management/internals/modules/reverseproxy/proxy/manager/metrics.go b/management/internals/modules/reverseproxy/proxy/manager/metrics.go new file mode 100644 index 000000000..2b402cead --- /dev/null +++ b/management/internals/modules/reverseproxy/proxy/manager/metrics.go @@ -0,0 +1,74 @@ +package manager + +import ( + "context" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +type metrics struct { + proxyConnectionCount metric.Int64UpDownCounter + serviceUpdateSendCount metric.Int64Counter + proxyHeartbeatCount metric.Int64Counter +} + +func newMetrics(meter metric.Meter) (*metrics, error) { + proxyConnectionCount, err := meter.Int64UpDownCounter( + "management_proxy_connection_count", + metric.WithDescription("Number of active proxy connections"), + metric.WithUnit("{connection}"), + ) + if err != nil { + return nil, err + } + + serviceUpdateSendCount, err := meter.Int64Counter( + "management_proxy_service_update_send_count", + metric.WithDescription("Total number of service updates sent to proxies"), + metric.WithUnit("{update}"), + ) + if err != nil { + return nil, err + } + + proxyHeartbeatCount, err := meter.Int64Counter( + "management_proxy_heartbeat_count", + metric.WithDescription("Total number of proxy heartbeats received"), + metric.WithUnit("{heartbeat}"), + ) + if err != nil { + return nil, err + } + + return &metrics{ + proxyConnectionCount: proxyConnectionCount, + serviceUpdateSendCount: serviceUpdateSendCount, + proxyHeartbeatCount: proxyHeartbeatCount, + }, nil +} + +func (m *metrics) IncrementProxyConnectionCount(clusterAddr string) { + m.proxyConnectionCount.Add(context.Background(), 1, + metric.WithAttributes( + attribute.String("cluster", clusterAddr), + )) +} + +func (m *metrics) DecrementProxyConnectionCount(clusterAddr string) { + m.proxyConnectionCount.Add(context.Background(), -1, + metric.WithAttributes( + attribute.String("cluster", clusterAddr), + )) +} + +func (m *metrics) IncrementServiceUpdateSendCount(clusterAddr string) { + m.serviceUpdateSendCount.Add(context.Background(), 1, + metric.WithAttributes( + attribute.String("cluster", clusterAddr), + )) +} + +func (m *metrics) IncrementProxyHeartbeatCount() { + m.proxyHeartbeatCount.Add(context.Background(), 1) +} diff --git a/management/internals/modules/reverseproxy/proxy/manager_mock.go b/management/internals/modules/reverseproxy/proxy/manager_mock.go new file mode 100644 index 000000000..b07a21122 --- /dev/null +++ b/management/internals/modules/reverseproxy/proxy/manager_mock.go @@ -0,0 +1,213 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./manager.go + +// Package proxy is a generated GoMock package. +package proxy + +import ( + context "context" + reflect "reflect" + time "time" + + gomock "github.com/golang/mock/gomock" + proto "github.com/netbirdio/netbird/shared/management/proto" +) + +// MockManager is a mock of Manager interface. +type MockManager struct { + ctrl *gomock.Controller + recorder *MockManagerMockRecorder +} + +// MockManagerMockRecorder is the mock recorder for MockManager. +type MockManagerMockRecorder struct { + mock *MockManager +} + +// NewMockManager creates a new mock instance. +func NewMockManager(ctrl *gomock.Controller) *MockManager { + mock := &MockManager{ctrl: ctrl} + mock.recorder = &MockManagerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockManager) EXPECT() *MockManagerMockRecorder { + return m.recorder +} + +// CleanupStale mocks base method. +func (m *MockManager) CleanupStale(ctx context.Context, inactivityDuration time.Duration) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CleanupStale", ctx, inactivityDuration) + ret0, _ := ret[0].(error) + return ret0 +} + +// CleanupStale indicates an expected call of CleanupStale. +func (mr *MockManagerMockRecorder) CleanupStale(ctx, inactivityDuration interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CleanupStale", reflect.TypeOf((*MockManager)(nil).CleanupStale), ctx, inactivityDuration) +} + +// Connect mocks base method. +func (m *MockManager) Connect(ctx context.Context, proxyID, clusterAddress, ipAddress string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Connect", ctx, proxyID, clusterAddress, ipAddress) + ret0, _ := ret[0].(error) + return ret0 +} + +// Connect indicates an expected call of Connect. +func (mr *MockManagerMockRecorder) Connect(ctx, proxyID, clusterAddress, ipAddress interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Connect", reflect.TypeOf((*MockManager)(nil).Connect), ctx, proxyID, clusterAddress, ipAddress) +} + +// Disconnect mocks base method. +func (m *MockManager) Disconnect(ctx context.Context, proxyID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Disconnect", ctx, proxyID) + ret0, _ := ret[0].(error) + return ret0 +} + +// Disconnect indicates an expected call of Disconnect. +func (mr *MockManagerMockRecorder) Disconnect(ctx, proxyID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Disconnect", reflect.TypeOf((*MockManager)(nil).Disconnect), ctx, proxyID) +} + +// GetActiveClusterAddresses mocks base method. +func (m *MockManager) GetActiveClusterAddresses(ctx context.Context) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetActiveClusterAddresses", ctx) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetActiveClusterAddresses indicates an expected call of GetActiveClusterAddresses. +func (mr *MockManagerMockRecorder) GetActiveClusterAddresses(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveClusterAddresses", reflect.TypeOf((*MockManager)(nil).GetActiveClusterAddresses), ctx) +} + +// Heartbeat mocks base method. +func (m *MockManager) Heartbeat(ctx context.Context, proxyID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Heartbeat", ctx, proxyID) + ret0, _ := ret[0].(error) + return ret0 +} + +// Heartbeat indicates an expected call of Heartbeat. +func (mr *MockManagerMockRecorder) Heartbeat(ctx, proxyID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Heartbeat", reflect.TypeOf((*MockManager)(nil).Heartbeat), ctx, proxyID) +} + +// MockController is a mock of Controller interface. +type MockController struct { + ctrl *gomock.Controller + recorder *MockControllerMockRecorder +} + +// MockControllerMockRecorder is the mock recorder for MockController. +type MockControllerMockRecorder struct { + mock *MockController +} + +// NewMockController creates a new mock instance. +func NewMockController(ctrl *gomock.Controller) *MockController { + mock := &MockController{ctrl: ctrl} + mock.recorder = &MockControllerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockController) EXPECT() *MockControllerMockRecorder { + return m.recorder +} + +// GetOIDCValidationConfig mocks base method. +func (m *MockController) GetOIDCValidationConfig() OIDCValidationConfig { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOIDCValidationConfig") + ret0, _ := ret[0].(OIDCValidationConfig) + return ret0 +} + +// GetOIDCValidationConfig indicates an expected call of GetOIDCValidationConfig. +func (mr *MockControllerMockRecorder) GetOIDCValidationConfig() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOIDCValidationConfig", reflect.TypeOf((*MockController)(nil).GetOIDCValidationConfig)) +} + +// ClusterSupportsCustomPorts mocks base method. +func (m *MockController) ClusterSupportsCustomPorts(clusterAddr string) *bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ClusterSupportsCustomPorts", clusterAddr) + ret0, _ := ret[0].(*bool) + return ret0 +} + +// ClusterSupportsCustomPorts indicates an expected call of ClusterSupportsCustomPorts. +func (mr *MockControllerMockRecorder) ClusterSupportsCustomPorts(clusterAddr interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsCustomPorts", reflect.TypeOf((*MockController)(nil).ClusterSupportsCustomPorts), clusterAddr) +} + +// GetProxiesForCluster mocks base method. +func (m *MockController) GetProxiesForCluster(clusterAddr string) []string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetProxiesForCluster", clusterAddr) + ret0, _ := ret[0].([]string) + return ret0 +} + +// GetProxiesForCluster indicates an expected call of GetProxiesForCluster. +func (mr *MockControllerMockRecorder) GetProxiesForCluster(clusterAddr interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProxiesForCluster", reflect.TypeOf((*MockController)(nil).GetProxiesForCluster), clusterAddr) +} + +// RegisterProxyToCluster mocks base method. +func (m *MockController) RegisterProxyToCluster(ctx context.Context, clusterAddr, proxyID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RegisterProxyToCluster", ctx, clusterAddr, proxyID) + ret0, _ := ret[0].(error) + return ret0 +} + +// RegisterProxyToCluster indicates an expected call of RegisterProxyToCluster. +func (mr *MockControllerMockRecorder) RegisterProxyToCluster(ctx, clusterAddr, proxyID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterProxyToCluster", reflect.TypeOf((*MockController)(nil).RegisterProxyToCluster), ctx, clusterAddr, proxyID) +} + +// SendServiceUpdateToCluster mocks base method. +func (m *MockController) SendServiceUpdateToCluster(ctx context.Context, accountID string, update *proto.ProxyMapping, clusterAddr string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SendServiceUpdateToCluster", ctx, accountID, update, clusterAddr) +} + +// SendServiceUpdateToCluster indicates an expected call of SendServiceUpdateToCluster. +func (mr *MockControllerMockRecorder) SendServiceUpdateToCluster(ctx, accountID, update, clusterAddr interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendServiceUpdateToCluster", reflect.TypeOf((*MockController)(nil).SendServiceUpdateToCluster), ctx, accountID, update, clusterAddr) +} + +// UnregisterProxyFromCluster mocks base method. +func (m *MockController) UnregisterProxyFromCluster(ctx context.Context, clusterAddr, proxyID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UnregisterProxyFromCluster", ctx, clusterAddr, proxyID) + ret0, _ := ret[0].(error) + return ret0 +} + +// UnregisterProxyFromCluster indicates an expected call of UnregisterProxyFromCluster. +func (mr *MockControllerMockRecorder) UnregisterProxyFromCluster(ctx, clusterAddr, proxyID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnregisterProxyFromCluster", reflect.TypeOf((*MockController)(nil).UnregisterProxyFromCluster), ctx, clusterAddr, proxyID) +} diff --git a/management/internals/modules/reverseproxy/proxy/proxy.go b/management/internals/modules/reverseproxy/proxy/proxy.go new file mode 100644 index 000000000..699e1ed02 --- /dev/null +++ b/management/internals/modules/reverseproxy/proxy/proxy.go @@ -0,0 +1,20 @@ +package proxy + +import "time" + +// Proxy represents a reverse proxy instance +type Proxy struct { + ID string `gorm:"primaryKey;type:varchar(255)"` + ClusterAddress string `gorm:"type:varchar(255);not null;index:idx_proxy_cluster_status"` + IPAddress string `gorm:"type:varchar(45)"` + LastSeen time.Time `gorm:"not null;index:idx_proxy_last_seen"` + ConnectedAt *time.Time + DisconnectedAt *time.Time + Status string `gorm:"type:varchar(20);not null;index:idx_proxy_cluster_status"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (Proxy) TableName() string { + return "proxies" +} diff --git a/management/internals/modules/reverseproxy/service/interface.go b/management/internals/modules/reverseproxy/service/interface.go new file mode 100644 index 000000000..39fd7e3ae --- /dev/null +++ b/management/internals/modules/reverseproxy/service/interface.go @@ -0,0 +1,28 @@ +package service + +//go:generate go run github.com/golang/mock/mockgen -package service -destination=interface_mock.go -source=./interface.go -build_flags=-mod=mod + +import ( + "context" +) + +type Manager interface { + GetAllServices(ctx context.Context, accountID, userID string) ([]*Service, error) + GetService(ctx context.Context, accountID, userID, serviceID string) (*Service, error) + CreateService(ctx context.Context, accountID, userID string, service *Service) (*Service, error) + UpdateService(ctx context.Context, accountID, userID string, service *Service) (*Service, error) + DeleteService(ctx context.Context, accountID, userID, serviceID string) error + DeleteAllServices(ctx context.Context, accountID, userID string) error + SetCertificateIssuedAt(ctx context.Context, accountID, serviceID string) error + SetStatus(ctx context.Context, accountID, serviceID string, status Status) error + ReloadAllServicesForAccount(ctx context.Context, accountID string) error + ReloadService(ctx context.Context, accountID, serviceID string) error + GetGlobalServices(ctx context.Context) ([]*Service, error) + GetServiceByID(ctx context.Context, accountID, serviceID string) (*Service, error) + GetAccountServices(ctx context.Context, accountID string) ([]*Service, error) + GetServiceIDByTargetID(ctx context.Context, accountID string, resourceID string) (string, error) + CreateServiceFromPeer(ctx context.Context, accountID, peerID string, req *ExposeServiceRequest) (*ExposeServiceResponse, error) + RenewServiceFromPeer(ctx context.Context, accountID, peerID, serviceID string) error + StopServiceFromPeer(ctx context.Context, accountID, peerID, serviceID string) error + StartExposeReaper(ctx context.Context) +} diff --git a/management/internals/modules/reverseproxy/service/interface_mock.go b/management/internals/modules/reverseproxy/service/interface_mock.go new file mode 100644 index 000000000..bdc1f3e65 --- /dev/null +++ b/management/internals/modules/reverseproxy/service/interface_mock.go @@ -0,0 +1,294 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./interface.go + +// Package service is a generated GoMock package. +package service + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// MockManager is a mock of Manager interface. +type MockManager struct { + ctrl *gomock.Controller + recorder *MockManagerMockRecorder +} + +// MockManagerMockRecorder is the mock recorder for MockManager. +type MockManagerMockRecorder struct { + mock *MockManager +} + +// NewMockManager creates a new mock instance. +func NewMockManager(ctrl *gomock.Controller) *MockManager { + mock := &MockManager{ctrl: ctrl} + mock.recorder = &MockManagerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockManager) EXPECT() *MockManagerMockRecorder { + return m.recorder +} + +// CreateService mocks base method. +func (m *MockManager) CreateService(ctx context.Context, accountID, userID string, service *Service) (*Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateService", ctx, accountID, userID, service) + ret0, _ := ret[0].(*Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateService indicates an expected call of CreateService. +func (mr *MockManagerMockRecorder) CreateService(ctx, accountID, userID, service interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateService", reflect.TypeOf((*MockManager)(nil).CreateService), ctx, accountID, userID, service) +} + +// CreateServiceFromPeer mocks base method. +func (m *MockManager) CreateServiceFromPeer(ctx context.Context, accountID, peerID string, req *ExposeServiceRequest) (*ExposeServiceResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateServiceFromPeer", ctx, accountID, peerID, req) + ret0, _ := ret[0].(*ExposeServiceResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateServiceFromPeer indicates an expected call of CreateServiceFromPeer. +func (mr *MockManagerMockRecorder) CreateServiceFromPeer(ctx, accountID, peerID, req interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateServiceFromPeer", reflect.TypeOf((*MockManager)(nil).CreateServiceFromPeer), ctx, accountID, peerID, req) +} + +// DeleteAllServices mocks base method. +func (m *MockManager) DeleteAllServices(ctx context.Context, accountID, userID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAllServices", ctx, accountID, userID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAllServices indicates an expected call of DeleteAllServices. +func (mr *MockManagerMockRecorder) DeleteAllServices(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAllServices", reflect.TypeOf((*MockManager)(nil).DeleteAllServices), ctx, accountID, userID) +} + +// DeleteService mocks base method. +func (m *MockManager) DeleteService(ctx context.Context, accountID, userID, serviceID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteService", ctx, accountID, userID, serviceID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteService indicates an expected call of DeleteService. +func (mr *MockManagerMockRecorder) DeleteService(ctx, accountID, userID, serviceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteService", reflect.TypeOf((*MockManager)(nil).DeleteService), ctx, accountID, userID, serviceID) +} + +// GetAccountServices mocks base method. +func (m *MockManager) GetAccountServices(ctx context.Context, accountID string) ([]*Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountServices", ctx, accountID) + ret0, _ := ret[0].([]*Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountServices indicates an expected call of GetAccountServices. +func (mr *MockManagerMockRecorder) GetAccountServices(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountServices", reflect.TypeOf((*MockManager)(nil).GetAccountServices), ctx, accountID) +} + +// GetAllServices mocks base method. +func (m *MockManager) GetAllServices(ctx context.Context, accountID, userID string) ([]*Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllServices", ctx, accountID, userID) + ret0, _ := ret[0].([]*Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAllServices indicates an expected call of GetAllServices. +func (mr *MockManagerMockRecorder) GetAllServices(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllServices", reflect.TypeOf((*MockManager)(nil).GetAllServices), ctx, accountID, userID) +} + +// GetGlobalServices mocks base method. +func (m *MockManager) GetGlobalServices(ctx context.Context) ([]*Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGlobalServices", ctx) + ret0, _ := ret[0].([]*Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetGlobalServices indicates an expected call of GetGlobalServices. +func (mr *MockManagerMockRecorder) GetGlobalServices(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGlobalServices", reflect.TypeOf((*MockManager)(nil).GetGlobalServices), ctx) +} + +// GetService mocks base method. +func (m *MockManager) GetService(ctx context.Context, accountID, userID, serviceID string) (*Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetService", ctx, accountID, userID, serviceID) + ret0, _ := ret[0].(*Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetService indicates an expected call of GetService. +func (mr *MockManagerMockRecorder) GetService(ctx, accountID, userID, serviceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetService", reflect.TypeOf((*MockManager)(nil).GetService), ctx, accountID, userID, serviceID) +} + +// GetServiceByID mocks base method. +func (m *MockManager) GetServiceByID(ctx context.Context, accountID, serviceID string) (*Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetServiceByID", ctx, accountID, serviceID) + ret0, _ := ret[0].(*Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetServiceByID indicates an expected call of GetServiceByID. +func (mr *MockManagerMockRecorder) GetServiceByID(ctx, accountID, serviceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServiceByID", reflect.TypeOf((*MockManager)(nil).GetServiceByID), ctx, accountID, serviceID) +} + +// GetServiceIDByTargetID mocks base method. +func (m *MockManager) GetServiceIDByTargetID(ctx context.Context, accountID, resourceID string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetServiceIDByTargetID", ctx, accountID, resourceID) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetServiceIDByTargetID indicates an expected call of GetServiceIDByTargetID. +func (mr *MockManagerMockRecorder) GetServiceIDByTargetID(ctx, accountID, resourceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServiceIDByTargetID", reflect.TypeOf((*MockManager)(nil).GetServiceIDByTargetID), ctx, accountID, resourceID) +} + +// ReloadAllServicesForAccount mocks base method. +func (m *MockManager) ReloadAllServicesForAccount(ctx context.Context, accountID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReloadAllServicesForAccount", ctx, accountID) + ret0, _ := ret[0].(error) + return ret0 +} + +// ReloadAllServicesForAccount indicates an expected call of ReloadAllServicesForAccount. +func (mr *MockManagerMockRecorder) ReloadAllServicesForAccount(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReloadAllServicesForAccount", reflect.TypeOf((*MockManager)(nil).ReloadAllServicesForAccount), ctx, accountID) +} + +// ReloadService mocks base method. +func (m *MockManager) ReloadService(ctx context.Context, accountID, serviceID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReloadService", ctx, accountID, serviceID) + ret0, _ := ret[0].(error) + return ret0 +} + +// ReloadService indicates an expected call of ReloadService. +func (mr *MockManagerMockRecorder) ReloadService(ctx, accountID, serviceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReloadService", reflect.TypeOf((*MockManager)(nil).ReloadService), ctx, accountID, serviceID) +} + +// RenewServiceFromPeer mocks base method. +func (m *MockManager) RenewServiceFromPeer(ctx context.Context, accountID, peerID, serviceID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RenewServiceFromPeer", ctx, accountID, peerID, serviceID) + ret0, _ := ret[0].(error) + return ret0 +} + +// RenewServiceFromPeer indicates an expected call of RenewServiceFromPeer. +func (mr *MockManagerMockRecorder) RenewServiceFromPeer(ctx, accountID, peerID, serviceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenewServiceFromPeer", reflect.TypeOf((*MockManager)(nil).RenewServiceFromPeer), ctx, accountID, peerID, serviceID) +} + +// SetCertificateIssuedAt mocks base method. +func (m *MockManager) SetCertificateIssuedAt(ctx context.Context, accountID, serviceID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetCertificateIssuedAt", ctx, accountID, serviceID) + ret0, _ := ret[0].(error) + return ret0 +} + +// SetCertificateIssuedAt indicates an expected call of SetCertificateIssuedAt. +func (mr *MockManagerMockRecorder) SetCertificateIssuedAt(ctx, accountID, serviceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetCertificateIssuedAt", reflect.TypeOf((*MockManager)(nil).SetCertificateIssuedAt), ctx, accountID, serviceID) +} + +// SetStatus mocks base method. +func (m *MockManager) SetStatus(ctx context.Context, accountID, serviceID string, status Status) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetStatus", ctx, accountID, serviceID, status) + ret0, _ := ret[0].(error) + return ret0 +} + +// SetStatus indicates an expected call of SetStatus. +func (mr *MockManagerMockRecorder) SetStatus(ctx, accountID, serviceID, status interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetStatus", reflect.TypeOf((*MockManager)(nil).SetStatus), ctx, accountID, serviceID, status) +} + +// StartExposeReaper mocks base method. +func (m *MockManager) StartExposeReaper(ctx context.Context) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "StartExposeReaper", ctx) +} + +// StartExposeReaper indicates an expected call of StartExposeReaper. +func (mr *MockManagerMockRecorder) StartExposeReaper(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartExposeReaper", reflect.TypeOf((*MockManager)(nil).StartExposeReaper), ctx) +} + +// StopServiceFromPeer mocks base method. +func (m *MockManager) StopServiceFromPeer(ctx context.Context, accountID, peerID, serviceID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StopServiceFromPeer", ctx, accountID, peerID, serviceID) + ret0, _ := ret[0].(error) + return ret0 +} + +// StopServiceFromPeer indicates an expected call of StopServiceFromPeer. +func (mr *MockManagerMockRecorder) StopServiceFromPeer(ctx, accountID, peerID, serviceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StopServiceFromPeer", reflect.TypeOf((*MockManager)(nil).StopServiceFromPeer), ctx, accountID, peerID, serviceID) +} + +// UpdateService mocks base method. +func (m *MockManager) UpdateService(ctx context.Context, accountID, userID string, service *Service) (*Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateService", ctx, accountID, userID, service) + ret0, _ := ret[0].(*Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateService indicates an expected call of UpdateService. +func (mr *MockManagerMockRecorder) UpdateService(ctx, accountID, userID, service interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateService", reflect.TypeOf((*MockManager)(nil).UpdateService), ctx, accountID, userID, service) +} diff --git a/management/internals/modules/reverseproxy/service/manager/api.go b/management/internals/modules/reverseproxy/service/manager/api.go new file mode 100644 index 000000000..c53219d2e --- /dev/null +++ b/management/internals/modules/reverseproxy/service/manager/api.go @@ -0,0 +1,179 @@ +package manager + +import ( + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + accesslogsmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs/manager" + domainmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain/manager" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" + "github.com/netbirdio/netbird/shared/management/status" +) + +type handler struct { + manager rpservice.Manager + permissionsManager permissions.Manager +} + +// RegisterEndpoints registers all service HTTP endpoints. +func RegisterEndpoints(manager rpservice.Manager, domainManager domainmanager.Manager, accessLogsManager accesslogs.Manager, permissionsManager permissions.Manager, router *mux.Router) { + h := &handler{ + manager: manager, + permissionsManager: permissionsManager, + } + + domainRouter := router.PathPrefix("/reverse-proxies").Subrouter() + domainmanager.RegisterEndpoints(domainRouter, domainManager) + + accesslogsmanager.RegisterEndpoints(router, accessLogsManager) + + router.HandleFunc("/reverse-proxies/services", h.getAllServices).Methods("GET", "OPTIONS") + router.HandleFunc("/reverse-proxies/services", h.createService).Methods("POST", "OPTIONS") + router.HandleFunc("/reverse-proxies/services/{serviceId}", h.getService).Methods("GET", "OPTIONS") + router.HandleFunc("/reverse-proxies/services/{serviceId}", h.updateService).Methods("PUT", "OPTIONS") + router.HandleFunc("/reverse-proxies/services/{serviceId}", h.deleteService).Methods("DELETE", "OPTIONS") +} + +func (h *handler) getAllServices(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + allServices, err := h.manager.GetAllServices(r.Context(), userAuth.AccountId, userAuth.UserId) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + apiServices := make([]*api.Service, 0, len(allServices)) + for _, service := range allServices { + apiServices = append(apiServices, service.ToAPIResponse()) + } + + util.WriteJSONObject(r.Context(), w, apiServices) +} + +func (h *handler) createService(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + var req api.ServiceRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + service := new(rpservice.Service) + if err = service.FromAPIRequest(&req, userAuth.AccountId); err != nil { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "%s", err.Error()), w) + return + } + + if err = service.Validate(); err != nil { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "%s", err.Error()), w) + return + } + + createdService, err := h.manager.CreateService(r.Context(), userAuth.AccountId, userAuth.UserId, service) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, createdService.ToAPIResponse()) +} + +func (h *handler) getService(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + serviceID := mux.Vars(r)["serviceId"] + if serviceID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "service ID is required"), w) + return + } + + service, err := h.manager.GetService(r.Context(), userAuth.AccountId, userAuth.UserId, serviceID) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, service.ToAPIResponse()) +} + +func (h *handler) updateService(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + serviceID := mux.Vars(r)["serviceId"] + if serviceID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "service ID is required"), w) + return + } + + var req api.ServiceRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + service := new(rpservice.Service) + service.ID = serviceID + if err = service.FromAPIRequest(&req, userAuth.AccountId); err != nil { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "%s", err.Error()), w) + return + } + + if err = service.Validate(); err != nil { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "%s", err.Error()), w) + return + } + + updatedService, err := h.manager.UpdateService(r.Context(), userAuth.AccountId, userAuth.UserId, service) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, updatedService.ToAPIResponse()) +} + +func (h *handler) deleteService(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + serviceID := mux.Vars(r)["serviceId"] + if serviceID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "service ID is required"), w) + return + } + + if err := h.manager.DeleteService(r.Context(), userAuth.AccountId, userAuth.UserId, serviceID); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, util.EmptyObject{}) +} diff --git a/management/internals/modules/reverseproxy/service/manager/expose_tracker.go b/management/internals/modules/reverseproxy/service/manager/expose_tracker.go new file mode 100644 index 000000000..911add3bb --- /dev/null +++ b/management/internals/modules/reverseproxy/service/manager/expose_tracker.go @@ -0,0 +1,65 @@ +package manager + +import ( + "context" + "math/rand/v2" + "time" + + "github.com/netbirdio/netbird/shared/management/status" + log "github.com/sirupsen/logrus" +) + +const ( + exposeTTL = 90 * time.Second + exposeReapInterval = 30 * time.Second + maxExposesPerPeer = 10 + exposeReapBatch = 100 +) + +type exposeReaper struct { + manager *Manager +} + +// StartExposeReaper starts a background goroutine that reaps expired ephemeral services from the DB. +func (r *exposeReaper) StartExposeReaper(ctx context.Context) { + go func() { + // start with a random delay + rn := rand.IntN(10) + time.Sleep(time.Duration(rn) * time.Second) + + ticker := time.NewTicker(exposeReapInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + r.reapExpiredExposes(ctx) + } + } + }() +} + +func (r *exposeReaper) reapExpiredExposes(ctx context.Context) { + expired, err := r.manager.store.GetExpiredEphemeralServices(ctx, exposeTTL, exposeReapBatch) + if err != nil { + log.Errorf("failed to get expired ephemeral services: %v", err) + return + } + + for _, svc := range expired { + log.Infof("reaping expired expose session for peer %s, domain %s", svc.SourcePeer, svc.Domain) + + err := r.manager.deleteExpiredPeerService(ctx, svc.AccountID, svc.SourcePeer, svc.ID) + if err == nil { + continue + } + + if s, ok := status.FromError(err); ok && s.ErrorType == status.NotFound { + log.Debugf("service %s was already deleted by another instance", svc.Domain) + } else { + log.Errorf("failed to delete expired peer-exposed service for domain %s: %v", svc.Domain, err) + } + } +} diff --git a/management/internals/modules/reverseproxy/service/manager/expose_tracker_test.go b/management/internals/modules/reverseproxy/service/manager/expose_tracker_test.go new file mode 100644 index 000000000..6ff8343b9 --- /dev/null +++ b/management/internals/modules/reverseproxy/service/manager/expose_tracker_test.go @@ -0,0 +1,221 @@ +package manager + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/store" +) + +func TestReapExpiredExposes(t *testing.T) { + mgr, testStore := setupIntegrationTest(t) + ctx := context.Background() + + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 8080, + Mode: "http", + }) + require.NoError(t, err) + + // Manually expire the service by backdating meta_last_renewed_at + expireEphemeralService(t, testStore, testAccountID, resp.Domain) + + // Create a non-expired service + resp2, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 8081, + Mode: "http", + }) + require.NoError(t, err) + + mgr.exposeReaper.reapExpiredExposes(ctx) + + // Expired service should be deleted + _, err = testStore.GetServiceByDomain(ctx, resp.Domain) + require.Error(t, err, "expired service should be deleted") + + // Non-expired service should remain + _, err = testStore.GetServiceByDomain(ctx, resp2.Domain) + require.NoError(t, err, "active service should remain") +} + +func TestReapAlreadyDeletedService(t *testing.T) { + mgr, testStore := setupIntegrationTest(t) + ctx := context.Background() + + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 8080, + Mode: "http", + }) + require.NoError(t, err) + + expireEphemeralService(t, testStore, testAccountID, resp.Domain) + + // Delete the service before reaping + svcID := resolveServiceIDByDomain(t, testStore, resp.Domain) + err = mgr.StopServiceFromPeer(ctx, testAccountID, testPeerID, svcID) + require.NoError(t, err) + + // Reaping should handle the already-deleted service gracefully + mgr.exposeReaper.reapExpiredExposes(ctx) +} + +func TestConcurrentReapAndRenew(t *testing.T) { + mgr, testStore := setupIntegrationTest(t) + ctx := context.Background() + + for i := range 5 { + _, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: uint16(8080 + i), + Mode: "http", + }) + require.NoError(t, err) + } + + // Expire all services + services, err := testStore.GetAccountServices(ctx, store.LockingStrengthNone, testAccountID) + require.NoError(t, err) + for _, svc := range services { + if svc.Source == rpservice.SourceEphemeral { + expireEphemeralService(t, testStore, testAccountID, svc.Domain) + } + } + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + mgr.exposeReaper.reapExpiredExposes(ctx) + }() + go func() { + defer wg.Done() + _, _ = mgr.store.CountEphemeralServicesByPeer(ctx, store.LockingStrengthNone, testAccountID, testPeerID) + }() + wg.Wait() + + count, err := mgr.store.CountEphemeralServicesByPeer(ctx, store.LockingStrengthNone, testAccountID, testPeerID) + require.NoError(t, err) + assert.Equal(t, int64(0), count, "all expired services should be reaped") +} + +func TestRenewEphemeralService(t *testing.T) { + mgr, _ := setupIntegrationTest(t) + ctx := context.Background() + + t.Run("renew succeeds for active service", func(t *testing.T) { + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 8082, + Mode: "http", + }) + require.NoError(t, err) + + svc, lookupErr := mgr.store.GetServiceByDomain(ctx, resp.Domain) + require.NoError(t, lookupErr) + err = mgr.RenewServiceFromPeer(ctx, testAccountID, testPeerID, svc.ID) + require.NoError(t, err) + }) + + t.Run("renew fails for nonexistent domain", func(t *testing.T) { + err := mgr.RenewServiceFromPeer(ctx, testAccountID, testPeerID, "nonexistent-service-id") + require.Error(t, err) + assert.Contains(t, err.Error(), "no active expose session") + }) +} + +func TestCountAndExistsEphemeralServices(t *testing.T) { + mgr, _ := setupIntegrationTest(t) + ctx := context.Background() + + count, err := mgr.store.CountEphemeralServicesByPeer(ctx, store.LockingStrengthNone, testAccountID, testPeerID) + require.NoError(t, err) + assert.Equal(t, int64(0), count) + + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 8083, + Mode: "http", + }) + require.NoError(t, err) + + count, err = mgr.store.CountEphemeralServicesByPeer(ctx, store.LockingStrengthNone, testAccountID, testPeerID) + require.NoError(t, err) + assert.Equal(t, int64(1), count) + + exists, err := mgr.store.EphemeralServiceExists(ctx, store.LockingStrengthNone, testAccountID, testPeerID, resp.Domain) + require.NoError(t, err) + assert.True(t, exists, "service should exist") + + exists, err = mgr.store.EphemeralServiceExists(ctx, store.LockingStrengthNone, testAccountID, testPeerID, "no-such.domain") + require.NoError(t, err) + assert.False(t, exists, "non-existent service should not exist") +} + +func TestMaxExposesPerPeerEnforced(t *testing.T) { + mgr, _ := setupIntegrationTest(t) + ctx := context.Background() + + for i := range maxExposesPerPeer { + _, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: uint16(8090 + i), + Mode: "http", + }) + require.NoError(t, err, "expose %d should succeed", i) + } + + _, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 9999, + Mode: "http", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "maximum number of active expose sessions") +} + +func TestReapSkipsRenewedService(t *testing.T) { + mgr, testStore := setupIntegrationTest(t) + ctx := context.Background() + + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 8086, + Mode: "http", + }) + require.NoError(t, err) + + // Expire the service + expireEphemeralService(t, testStore, testAccountID, resp.Domain) + + // Renew it before the reaper runs + svc, err := testStore.GetServiceByDomain(ctx, resp.Domain) + require.NoError(t, err) + err = mgr.RenewServiceFromPeer(ctx, testAccountID, testPeerID, svc.ID) + require.NoError(t, err) + + // Reaper should skip it because the re-check sees a fresh timestamp + mgr.exposeReaper.reapExpiredExposes(ctx) + + _, err = testStore.GetServiceByDomain(ctx, resp.Domain) + require.NoError(t, err, "renewed service should survive reaping") +} + +// resolveServiceIDByDomain looks up a service ID by domain in tests. +func resolveServiceIDByDomain(t *testing.T, s store.Store, domain string) string { + t.Helper() + svc, err := s.GetServiceByDomain(context.Background(), domain) + require.NoError(t, err) + return svc.ID +} + +// expireEphemeralService backdates meta_last_renewed_at to force expiration. +func expireEphemeralService(t *testing.T, s store.Store, accountID, domain string) { + t.Helper() + svc, err := s.GetServiceByDomain(context.Background(), domain) + require.NoError(t, err) + + expired := time.Now().Add(-2 * exposeTTL) + svc.Meta.LastRenewedAt = &expired + err = s.UpdateService(context.Background(), svc) + require.NoError(t, err) +} diff --git a/management/internals/modules/reverseproxy/service/manager/l4_port_test.go b/management/internals/modules/reverseproxy/service/manager/l4_port_test.go new file mode 100644 index 000000000..c7a61ddcf --- /dev/null +++ b/management/internals/modules/reverseproxy/service/manager/l4_port_test.go @@ -0,0 +1,582 @@ +package manager + +import ( + "context" + "net" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/mock_server" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" +) + +const testCluster = "test-cluster" + +func boolPtr(v bool) *bool { return &v } + +// setupL4Test creates a manager with a mock proxy controller for L4 port tests. +func setupL4Test(t *testing.T, customPortsSupported *bool) (*Manager, store.Store, *proxy.MockController) { + t.Helper() + + ctrl := gomock.NewController(t) + + ctx := context.Background() + testStore, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err) + t.Cleanup(cleanup) + + err = testStore.SaveAccount(ctx, &types.Account{ + Id: testAccountID, + CreatedBy: testUserID, + Settings: &types.Settings{ + PeerExposeEnabled: true, + PeerExposeGroups: []string{testGroupID}, + }, + Users: map[string]*types.User{ + testUserID: { + Id: testUserID, + AccountID: testAccountID, + Role: types.UserRoleAdmin, + }, + }, + Peers: map[string]*nbpeer.Peer{ + testPeerID: { + ID: testPeerID, + AccountID: testAccountID, + Key: "test-key", + DNSLabel: "test-peer", + Name: "test-peer", + IP: net.ParseIP("100.64.0.1"), + Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()}, + Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"}, + }, + }, + Groups: map[string]*types.Group{ + testGroupID: { + ID: testGroupID, + AccountID: testAccountID, + Name: "Expose Group", + }, + }, + }) + require.NoError(t, err) + + err = testStore.AddPeerToGroup(ctx, testAccountID, testPeerID, testGroupID) + require.NoError(t, err) + + mockCtrl := proxy.NewMockController(ctrl) + mockCtrl.EXPECT().ClusterSupportsCustomPorts(gomock.Any()).Return(customPortsSupported).AnyTimes() + mockCtrl.EXPECT().SendServiceUpdateToCluster(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + mockCtrl.EXPECT().GetOIDCValidationConfig().Return(proxy.OIDCValidationConfig{}).AnyTimes() + + accountMgr := &mock_server.MockAccountManager{ + StoreEventFunc: func(_ context.Context, _, _, _ string, _ activity.ActivityDescriber, _ map[string]any) {}, + UpdateAccountPeersFunc: func(_ context.Context, _ string) {}, + GetGroupByNameFunc: func(ctx context.Context, accountID, groupName string) (*types.Group, error) { + return testStore.GetGroupByName(ctx, store.LockingStrengthNone, groupName, accountID) + }, + } + + mgr := &Manager{ + store: testStore, + accountManager: accountMgr, + permissionsManager: permissions.NewManager(testStore), + proxyController: mockCtrl, + clusterDeriver: &testClusterDeriver{domains: []string{"test.netbird.io"}}, + } + mgr.exposeReaper = &exposeReaper{manager: mgr} + + return mgr, testStore, mockCtrl +} + +// seedService creates a service directly in the store for test setup. +func seedService(t *testing.T, s store.Store, name, protocol, domain, cluster string, port uint16) *rpservice.Service { + t.Helper() + + svc := &rpservice.Service{ + AccountID: testAccountID, + Name: name, + Mode: protocol, + Domain: domain, + ProxyCluster: cluster, + ListenPort: port, + Enabled: true, + Source: "permanent", + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: protocol, Port: 8080, Enabled: true}, + }, + } + svc.InitNewRecord() + err := s.CreateService(context.Background(), svc) + require.NoError(t, err) + return svc +} + +func TestPortConflict_TCPSamePortCluster(t *testing.T) { + mgr, testStore, _ := setupL4Test(t, boolPtr(true)) + ctx := context.Background() + + seedService(t, testStore, "existing-tcp", "tcp", testCluster, testCluster, 5432) + + svc := &rpservice.Service{ + AccountID: testAccountID, + Name: "conflicting-tcp", + Mode: "tcp", + Domain: "conflicting-tcp." + testCluster, + ProxyCluster: testCluster, + ListenPort: 5432, + Enabled: true, + Source: "permanent", + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 9090, Enabled: true}, + }, + } + svc.InitNewRecord() + + err := mgr.persistNewService(ctx, testAccountID, svc) + require.Error(t, err, "TCP+TCP on same port/cluster should be rejected") + assert.Contains(t, err.Error(), "already in use") +} + +func TestPortConflict_UDPSamePortCluster(t *testing.T) { + mgr, testStore, _ := setupL4Test(t, boolPtr(true)) + ctx := context.Background() + + seedService(t, testStore, "existing-udp", "udp", testCluster, testCluster, 5432) + + svc := &rpservice.Service{ + AccountID: testAccountID, + Name: "conflicting-udp", + Mode: "udp", + Domain: "conflicting-udp." + testCluster, + ProxyCluster: testCluster, + ListenPort: 5432, + Enabled: true, + Source: "permanent", + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "udp", Port: 9090, Enabled: true}, + }, + } + svc.InitNewRecord() + + err := mgr.persistNewService(ctx, testAccountID, svc) + require.Error(t, err, "UDP+UDP on same port/cluster should be rejected") + assert.Contains(t, err.Error(), "already in use") +} + +func TestPortConflict_TLSSamePortDifferentDomain(t *testing.T) { + mgr, testStore, _ := setupL4Test(t, boolPtr(true)) + ctx := context.Background() + + seedService(t, testStore, "existing-tls", "tls", "app1.example.com", testCluster, 443) + + svc := &rpservice.Service{ + AccountID: testAccountID, + Name: "new-tls", + Mode: "tls", + Domain: "app2.example.com", + ProxyCluster: testCluster, + ListenPort: 443, + Enabled: true, + Source: "permanent", + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8443, Enabled: true}, + }, + } + svc.InitNewRecord() + + err := mgr.persistNewService(ctx, testAccountID, svc) + assert.NoError(t, err, "TLS+TLS on same port with different domains should be allowed (SNI routing)") +} + +func TestPortConflict_TLSSamePortSameDomain(t *testing.T) { + mgr, testStore, _ := setupL4Test(t, boolPtr(true)) + ctx := context.Background() + + seedService(t, testStore, "existing-tls", "tls", "app.example.com", testCluster, 443) + + svc := &rpservice.Service{ + AccountID: testAccountID, + Name: "duplicate-tls", + Mode: "tls", + Domain: "app.example.com", + ProxyCluster: testCluster, + ListenPort: 443, + Enabled: true, + Source: "permanent", + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8443, Enabled: true}, + }, + } + svc.InitNewRecord() + + err := mgr.persistNewService(ctx, testAccountID, svc) + require.Error(t, err, "TLS+TLS on same domain should be rejected") + assert.Contains(t, err.Error(), "domain already taken") +} + +func TestPortConflict_TLSAndTCPSamePort(t *testing.T) { + mgr, testStore, _ := setupL4Test(t, boolPtr(true)) + ctx := context.Background() + + seedService(t, testStore, "existing-tls", "tls", "app.example.com", testCluster, 443) + + svc := &rpservice.Service{ + AccountID: testAccountID, + Name: "new-tcp", + Mode: "tcp", + Domain: testCluster, + ProxyCluster: testCluster, + ListenPort: 443, + Enabled: true, + Source: "permanent", + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8080, Enabled: true}, + }, + } + svc.InitNewRecord() + + err := mgr.persistNewService(ctx, testAccountID, svc) + assert.NoError(t, err, "TLS+TCP on same port should be allowed (multiplexed)") +} + +func TestAutoAssign_TCPNoListenPort(t *testing.T) { + mgr, _, _ := setupL4Test(t, boolPtr(false)) + ctx := context.Background() + + svc := &rpservice.Service{ + AccountID: testAccountID, + Name: "auto-tcp", + Mode: "tcp", + Domain: testCluster, + ProxyCluster: testCluster, + ListenPort: 0, + Enabled: true, + Source: "permanent", + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8080, Enabled: true}, + }, + } + svc.InitNewRecord() + + err := mgr.persistNewService(ctx, testAccountID, svc) + require.NoError(t, err) + assert.True(t, svc.ListenPort >= autoAssignPortMin && svc.ListenPort <= autoAssignPortMax, + "auto-assigned port %d should be in range [%d, %d]", svc.ListenPort, autoAssignPortMin, autoAssignPortMax) + assert.True(t, svc.PortAutoAssigned, "PortAutoAssigned should be set") +} + +func TestAutoAssign_TCPCustomPortRejectedWhenNotSupported(t *testing.T) { + mgr, _, _ := setupL4Test(t, boolPtr(false)) + ctx := context.Background() + + svc := &rpservice.Service{ + AccountID: testAccountID, + Name: "custom-tcp", + Mode: "tcp", + Domain: testCluster, + ProxyCluster: testCluster, + ListenPort: 5555, + Enabled: true, + Source: "permanent", + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8080, Enabled: true}, + }, + } + svc.InitNewRecord() + + err := mgr.persistNewService(ctx, testAccountID, svc) + require.Error(t, err, "TCP with custom port should be rejected when cluster doesn't support it") + assert.Contains(t, err.Error(), "custom ports") +} + +func TestAutoAssign_TLSCustomPortAlwaysAllowed(t *testing.T) { + mgr, _, _ := setupL4Test(t, boolPtr(false)) + ctx := context.Background() + + svc := &rpservice.Service{ + AccountID: testAccountID, + Name: "custom-tls", + Mode: "tls", + Domain: "app.example.com", + ProxyCluster: testCluster, + ListenPort: 9999, + Enabled: true, + Source: "permanent", + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8443, Enabled: true}, + }, + } + svc.InitNewRecord() + + err := mgr.persistNewService(ctx, testAccountID, svc) + assert.NoError(t, err, "TLS with custom port should always be allowed regardless of cluster capability") + assert.Equal(t, uint16(9999), svc.ListenPort, "TLS listen port should not be overridden") + assert.False(t, svc.PortAutoAssigned, "PortAutoAssigned should not be set for TLS") +} + +func TestAutoAssign_EphemeralOverridesPortWhenNotSupported(t *testing.T) { + mgr, _, _ := setupL4Test(t, boolPtr(false)) + ctx := context.Background() + + svc := &rpservice.Service{ + AccountID: testAccountID, + Name: "ephemeral-tcp", + Mode: "tcp", + Domain: testCluster, + ProxyCluster: testCluster, + ListenPort: 5555, + Enabled: true, + Source: "ephemeral", + SourcePeer: testPeerID, + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8080, Enabled: true}, + }, + } + svc.InitNewRecord() + + err := mgr.persistNewEphemeralService(ctx, testAccountID, testPeerID, svc) + require.NoError(t, err) + assert.NotEqual(t, uint16(5555), svc.ListenPort, "requested port should be overridden") + assert.True(t, svc.ListenPort >= autoAssignPortMin && svc.ListenPort <= autoAssignPortMax, + "auto-assigned port %d should be in range", svc.ListenPort) + assert.True(t, svc.PortAutoAssigned) +} + +func TestAutoAssign_EphemeralTLSKeepsCustomPort(t *testing.T) { + mgr, _, _ := setupL4Test(t, boolPtr(false)) + ctx := context.Background() + + svc := &rpservice.Service{ + AccountID: testAccountID, + Name: "ephemeral-tls", + Mode: "tls", + Domain: "app.example.com", + ProxyCluster: testCluster, + ListenPort: 9999, + Enabled: true, + Source: "ephemeral", + SourcePeer: testPeerID, + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8443, Enabled: true}, + }, + } + svc.InitNewRecord() + + err := mgr.persistNewEphemeralService(ctx, testAccountID, testPeerID, svc) + require.NoError(t, err) + assert.Equal(t, uint16(9999), svc.ListenPort, "TLS listen port should not be overridden") + assert.False(t, svc.PortAutoAssigned) +} + +func TestAutoAssign_AvoidsExistingPorts(t *testing.T) { + mgr, testStore, _ := setupL4Test(t, boolPtr(true)) + ctx := context.Background() + + existingPort := uint16(20000) + seedService(t, testStore, "existing", "tcp", testCluster, testCluster, existingPort) + + svc := &rpservice.Service{ + AccountID: testAccountID, + Name: "auto-tcp", + Mode: "tcp", + Domain: "auto-tcp." + testCluster, + ProxyCluster: testCluster, + ListenPort: 0, + Enabled: true, + Source: "permanent", + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8080, Enabled: true}, + }, + } + svc.InitNewRecord() + + err := mgr.persistNewService(ctx, testAccountID, svc) + require.NoError(t, err) + assert.NotEqual(t, existingPort, svc.ListenPort, "auto-assigned port should not collide with existing") + assert.True(t, svc.PortAutoAssigned) +} + +func TestAutoAssign_TCPCustomPortAllowedWhenSupported(t *testing.T) { + mgr, _, _ := setupL4Test(t, boolPtr(true)) + ctx := context.Background() + + svc := &rpservice.Service{ + AccountID: testAccountID, + Name: "custom-tcp", + Mode: "tcp", + Domain: testCluster, + ProxyCluster: testCluster, + ListenPort: 5555, + Enabled: true, + Source: "permanent", + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8080, Enabled: true}, + }, + } + svc.InitNewRecord() + + err := mgr.persistNewService(ctx, testAccountID, svc) + require.NoError(t, err) + assert.Equal(t, uint16(5555), svc.ListenPort, "custom port should be preserved when supported") + assert.False(t, svc.PortAutoAssigned) +} + +func TestUpdate_PreservesExistingListenPort(t *testing.T) { + mgr, testStore, _ := setupL4Test(t, boolPtr(true)) + ctx := context.Background() + + existing := seedService(t, testStore, "tcp-svc", "tcp", testCluster, testCluster, 12345) + + updated := &rpservice.Service{ + ID: existing.ID, + AccountID: testAccountID, + Name: "tcp-svc-renamed", + Mode: "tcp", + Domain: testCluster, + ProxyCluster: testCluster, + ListenPort: 0, + Enabled: true, + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 9090, Enabled: true}, + }, + } + + _, err := mgr.persistServiceUpdate(ctx, testAccountID, updated) + require.NoError(t, err) + assert.Equal(t, uint16(12345), updated.ListenPort, "existing listen port should be preserved when update sends 0") +} + +func TestUpdate_AllowsPortChange(t *testing.T) { + mgr, testStore, _ := setupL4Test(t, boolPtr(true)) + ctx := context.Background() + + existing := seedService(t, testStore, "tcp-svc", "tcp", testCluster, testCluster, 12345) + + updated := &rpservice.Service{ + ID: existing.ID, + AccountID: testAccountID, + Name: "tcp-svc", + Mode: "tcp", + Domain: testCluster, + ProxyCluster: testCluster, + ListenPort: 54321, + Enabled: true, + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 9090, Enabled: true}, + }, + } + + _, err := mgr.persistServiceUpdate(ctx, testAccountID, updated) + require.NoError(t, err) + assert.Equal(t, uint16(54321), updated.ListenPort, "explicit port change should be applied") +} + +func TestCreateServiceFromPeer_TCP(t *testing.T) { + mgr, _, _ := setupL4Test(t, boolPtr(false)) + ctx := context.Background() + + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 5432, + Mode: "tcp", + }) + require.NoError(t, err) + + assert.NotEmpty(t, resp.ServiceName) + assert.Contains(t, resp.Domain, ".test.netbird.io", "TCP uses unique subdomain") + assert.True(t, resp.PortAutoAssigned, "port should be auto-assigned when cluster doesn't support custom ports") + assert.Contains(t, resp.ServiceURL, "tcp://") +} + +func TestCreateServiceFromPeer_TCP_CustomPort(t *testing.T) { + mgr, _, _ := setupL4Test(t, boolPtr(true)) + ctx := context.Background() + + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 5432, + Mode: "tcp", + ListenPort: 15432, + }) + require.NoError(t, err) + + assert.False(t, resp.PortAutoAssigned) + assert.Contains(t, resp.ServiceURL, ":15432") +} + +func TestCreateServiceFromPeer_TCP_DefaultListenPort(t *testing.T) { + mgr, _, _ := setupL4Test(t, boolPtr(true)) + ctx := context.Background() + + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 5432, + Mode: "tcp", + }) + require.NoError(t, err) + + // When no explicit listen port, defaults to target port + assert.Contains(t, resp.ServiceURL, ":5432") + assert.False(t, resp.PortAutoAssigned) +} + +func TestCreateServiceFromPeer_TLS(t *testing.T) { + mgr, _, _ := setupL4Test(t, boolPtr(false)) + ctx := context.Background() + + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 443, + Mode: "tls", + }) + require.NoError(t, err) + + assert.Contains(t, resp.Domain, ".test.netbird.io", "TLS uses subdomain") + assert.Contains(t, resp.ServiceURL, "tls://") + assert.Contains(t, resp.ServiceURL, ":443") + // TLS always keeps its port (not port-based protocol for auto-assign) + assert.False(t, resp.PortAutoAssigned) +} + +func TestCreateServiceFromPeer_TCP_StopAndRenew(t *testing.T) { + mgr, testStore, _ := setupL4Test(t, boolPtr(true)) + ctx := context.Background() + + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 8080, + Mode: "tcp", + }) + require.NoError(t, err) + + svcID := resolveServiceIDByDomain(t, testStore, resp.Domain) + + err = mgr.RenewServiceFromPeer(ctx, testAccountID, testPeerID, svcID) + require.NoError(t, err) + + err = mgr.StopServiceFromPeer(ctx, testAccountID, testPeerID, svcID) + require.NoError(t, err) + + // Renew after stop should fail + err = mgr.RenewServiceFromPeer(ctx, testAccountID, testPeerID, svcID) + require.Error(t, err) +} + +func TestCreateServiceFromPeer_L4_RejectsAuth(t *testing.T) { + mgr, _, _ := setupL4Test(t, boolPtr(true)) + ctx := context.Background() + + _, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 8080, + Mode: "tcp", + Pin: "123456", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "authentication is not supported") +} diff --git a/management/internals/modules/reverseproxy/service/manager/manager.go b/management/internals/modules/reverseproxy/service/manager/manager.go new file mode 100644 index 000000000..65177bf5d --- /dev/null +++ b/management/internals/modules/reverseproxy/service/manager/manager.go @@ -0,0 +1,1150 @@ +package manager + +import ( + "context" + "fmt" + "math/rand/v2" + "net/http" + "os" + "slices" + "strconv" + "time" + + log "github.com/sirupsen/logrus" + + nbpeer "github.com/netbirdio/netbird/management/server/peer" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey" + "github.com/netbirdio/netbird/management/server/account" + "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/permissions/modules" + "github.com/netbirdio/netbird/management/server/permissions/operations" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/shared/management/status" +) + +const ( + defaultAutoAssignPortMin uint16 = 10000 + defaultAutoAssignPortMax uint16 = 49151 + + // EnvAutoAssignPortMin overrides the lower bound for auto-assigned L4 listen ports. + EnvAutoAssignPortMin = "NB_PROXY_PORT_MIN" + // EnvAutoAssignPortMax overrides the upper bound for auto-assigned L4 listen ports. + EnvAutoAssignPortMax = "NB_PROXY_PORT_MAX" +) + +var ( + autoAssignPortMin = defaultAutoAssignPortMin + autoAssignPortMax = defaultAutoAssignPortMax +) + +func init() { + autoAssignPortMin = portFromEnv(EnvAutoAssignPortMin, defaultAutoAssignPortMin) + autoAssignPortMax = portFromEnv(EnvAutoAssignPortMax, defaultAutoAssignPortMax) + if autoAssignPortMin > autoAssignPortMax { + log.Warnf("port range invalid: %s (%d) > %s (%d), using defaults", + EnvAutoAssignPortMin, autoAssignPortMin, EnvAutoAssignPortMax, autoAssignPortMax) + autoAssignPortMin = defaultAutoAssignPortMin + autoAssignPortMax = defaultAutoAssignPortMax + } +} + +func portFromEnv(key string, fallback uint16) uint16 { + val := os.Getenv(key) + if val == "" { + return fallback + } + n, err := strconv.ParseUint(val, 10, 16) + if err != nil { + log.Warnf("invalid %s value %q, using default %d: %v", key, val, fallback, err) + return fallback + } + return uint16(n) +} + +const unknownHostPlaceholder = "unknown" + +// ClusterDeriver derives the proxy cluster from a domain. +type ClusterDeriver interface { + DeriveClusterFromDomain(ctx context.Context, accountID, domain string) (string, error) + GetClusterDomains() []string +} + +type Manager struct { + store store.Store + accountManager account.Manager + permissionsManager permissions.Manager + proxyController proxy.Controller + clusterDeriver ClusterDeriver + exposeReaper *exposeReaper +} + +// NewManager creates a new service manager. +func NewManager(store store.Store, accountManager account.Manager, permissionsManager permissions.Manager, proxyController proxy.Controller, clusterDeriver ClusterDeriver) *Manager { + mgr := &Manager{ + store: store, + accountManager: accountManager, + permissionsManager: permissionsManager, + proxyController: proxyController, + clusterDeriver: clusterDeriver, + } + mgr.exposeReaper = &exposeReaper{manager: mgr} + return mgr +} + +// StartExposeReaper starts the background goroutine that reaps expired ephemeral services. +func (m *Manager) StartExposeReaper(ctx context.Context) { + m.exposeReaper.StartExposeReaper(ctx) +} + +func (m *Manager) GetAllServices(ctx context.Context, accountID, userID string) ([]*service.Service, error) { + ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return nil, status.NewPermissionDeniedError() + } + + services, err := m.store.GetAccountServices(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return nil, fmt.Errorf("failed to get services: %w", err) + } + + for _, service := range services { + err = m.replaceHostByLookup(ctx, accountID, service) + if err != nil { + return nil, fmt.Errorf("failed to replace host by lookup for service %s: %w", service.ID, err) + } + } + + return services, nil +} + +func (m *Manager) replaceHostByLookup(ctx context.Context, accountID string, s *service.Service) error { + for _, target := range s.Targets { + switch target.TargetType { + case service.TargetTypePeer: + peer, err := m.store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, target.TargetId) + if err != nil { + log.WithContext(ctx).Warnf("failed to get peer by id %s for service %s: %v", target.TargetId, s.ID, err) + target.Host = unknownHostPlaceholder + continue + } + target.Host = peer.IP.String() + case service.TargetTypeHost: + resource, err := m.store.GetNetworkResourceByID(ctx, store.LockingStrengthNone, accountID, target.TargetId) + if err != nil { + log.WithContext(ctx).Warnf("failed to get resource by id %s for service %s: %v", target.TargetId, s.ID, err) + target.Host = unknownHostPlaceholder + continue + } + target.Host = resource.Prefix.Addr().String() + case service.TargetTypeDomain: + resource, err := m.store.GetNetworkResourceByID(ctx, store.LockingStrengthNone, accountID, target.TargetId) + if err != nil { + log.WithContext(ctx).Warnf("failed to get resource by id %s for service %s: %v", target.TargetId, s.ID, err) + target.Host = unknownHostPlaceholder + continue + } + target.Host = resource.Domain + case service.TargetTypeSubnet: + // For subnets we do not do any lookups on the resource + default: + return fmt.Errorf("unknown target type: %s", target.TargetType) + } + } + + return nil +} + +func (m *Manager) GetService(ctx context.Context, accountID, userID, serviceID string) (*service.Service, error) { + ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return nil, status.NewPermissionDeniedError() + } + + service, err := m.store.GetServiceByID(ctx, store.LockingStrengthNone, accountID, serviceID) + if err != nil { + return nil, fmt.Errorf("failed to get service: %w", err) + } + + err = m.replaceHostByLookup(ctx, accountID, service) + if err != nil { + return nil, fmt.Errorf("failed to replace host by lookup for service %s: %w", service.ID, err) + } + return service, nil +} + +func (m *Manager) CreateService(ctx context.Context, accountID, userID string, s *service.Service) (*service.Service, error) { + ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return nil, status.NewPermissionDeniedError() + } + + if err := m.initializeServiceForCreate(ctx, accountID, s); err != nil { + return nil, err + } + + if err := m.persistNewService(ctx, accountID, s); err != nil { + return nil, err + } + + m.accountManager.StoreEvent(ctx, userID, s.ID, accountID, activity.ServiceCreated, s.EventMeta()) + + err = m.replaceHostByLookup(ctx, accountID, s) + if err != nil { + return nil, fmt.Errorf("failed to replace host by lookup for service %s: %w", s.ID, err) + } + + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Create, "", m.proxyController.GetOIDCValidationConfig()), s.ProxyCluster) + + m.accountManager.UpdateAccountPeers(ctx, accountID) + + return s, nil +} + +func (m *Manager) initializeServiceForCreate(ctx context.Context, accountID string, service *service.Service) error { + if m.clusterDeriver != nil { + proxyCluster, err := m.clusterDeriver.DeriveClusterFromDomain(ctx, accountID, service.Domain) + if err != nil { + log.WithError(err).Warnf("could not derive cluster from domain %s, updates will broadcast to all proxy servers", service.Domain) + return status.Errorf(status.PreconditionFailed, "could not derive cluster from domain %s: %v", service.Domain, err) + } + service.ProxyCluster = proxyCluster + } + + service.AccountID = accountID + service.InitNewRecord() + + if err := service.Auth.HashSecrets(); err != nil { + return fmt.Errorf("hash secrets: %w", err) + } + + for i, h := range service.Auth.HeaderAuths { + if h != nil && h.Enabled && h.Value == "" { + return status.Errorf(status.InvalidArgument, "header_auths[%d]: value is required", i) + } + } + + keyPair, err := sessionkey.GenerateKeyPair() + if err != nil { + return fmt.Errorf("generate session keys: %w", err) + } + service.SessionPrivateKey = keyPair.PrivateKey + service.SessionPublicKey = keyPair.PublicKey + + return nil +} + +func (m *Manager) persistNewService(ctx context.Context, accountID string, svc *service.Service) error { + return m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + if svc.Domain != "" { + if err := m.checkDomainAvailable(ctx, transaction, svc.Domain, ""); err != nil { + return err + } + } + + if err := m.ensureL4Port(ctx, transaction, svc); err != nil { + return err + } + + if err := m.checkPortConflict(ctx, transaction, svc); err != nil { + return err + } + + if err := validateTargetReferences(ctx, transaction, accountID, svc.Targets); err != nil { + return err + } + + if err := transaction.CreateService(ctx, svc); err != nil { + return fmt.Errorf("create service: %w", err) + } + + return nil + }) +} + +// ensureL4Port auto-assigns a listen port when needed and validates cluster support. +func (m *Manager) ensureL4Port(ctx context.Context, tx store.Store, svc *service.Service) error { + if !service.IsL4Protocol(svc.Mode) { + return nil + } + customPorts := m.proxyController.ClusterSupportsCustomPorts(svc.ProxyCluster) + if service.IsPortBasedProtocol(svc.Mode) && svc.ListenPort > 0 && (customPorts == nil || !*customPorts) { + if svc.Source != service.SourceEphemeral { + return status.Errorf(status.InvalidArgument, "custom ports not supported on cluster %s", svc.ProxyCluster) + } + svc.ListenPort = 0 + } + if svc.ListenPort == 0 { + port, err := m.assignPort(ctx, tx, svc.ProxyCluster) + if err != nil { + return err + } + svc.ListenPort = port + svc.PortAutoAssigned = true + } + return nil +} + +// checkPortConflict rejects L4 services that would conflict on the same listener. +// For TCP/UDP: unique per cluster+protocol+port. +// For TLS: unique per cluster+port+domain (SNI routing allows sharing ports). +// Cross-protocol conflicts (TLS vs raw TCP) are intentionally not checked: +// the proxy router multiplexes TLS (via SNI) and raw TCP (via fallback) on the same listener. +func (m *Manager) checkPortConflict(ctx context.Context, transaction store.Store, svc *service.Service) error { + if !service.IsL4Protocol(svc.Mode) || svc.ListenPort == 0 { + return nil + } + + existing, err := transaction.GetServicesByClusterAndPort(ctx, store.LockingStrengthUpdate, svc.ProxyCluster, svc.Mode, svc.ListenPort) + if err != nil { + return fmt.Errorf("query port conflicts: %w", err) + } + for _, s := range existing { + if s.ID == svc.ID { + continue + } + // TLS services on the same port are allowed if they have different domains (SNI routing) + if svc.Mode == service.ModeTLS && s.Domain != svc.Domain { + continue + } + return status.Errorf(status.AlreadyExists, + "%s port %d is already in use by service %q on cluster %s", + svc.Mode, svc.ListenPort, s.Name, svc.ProxyCluster) + } + + return nil +} + +// assignPort picks a random available port on the cluster within the auto-assign range. +func (m *Manager) assignPort(ctx context.Context, tx store.Store, cluster string) (uint16, error) { + services, err := tx.GetServicesByCluster(ctx, store.LockingStrengthUpdate, cluster) + if err != nil { + return 0, fmt.Errorf("query cluster ports: %w", err) + } + + occupied := make(map[uint16]struct{}, len(services)) + for _, s := range services { + if s.ListenPort > 0 { + occupied[s.ListenPort] = struct{}{} + } + } + + portRange := int(autoAssignPortMax-autoAssignPortMin) + 1 + for range 100 { + port := autoAssignPortMin + uint16(rand.IntN(portRange)) + if _, taken := occupied[port]; !taken { + return port, nil + } + } + + for port := autoAssignPortMin; port <= autoAssignPortMax; port++ { + if _, taken := occupied[port]; !taken { + return port, nil + } + } + + return 0, status.Errorf(status.PreconditionFailed, "no available ports on cluster %s", cluster) +} + +// persistNewEphemeralService creates an ephemeral service inside a single transaction +// that also enforces the duplicate and per-peer limit checks atomically. +// The count and exists queries use FOR UPDATE locking to serialize concurrent creates +// for the same peer, preventing the per-peer limit from being bypassed. +func (m *Manager) persistNewEphemeralService(ctx context.Context, accountID, peerID string, svc *service.Service) error { + return m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + if err := m.validateEphemeralPreconditions(ctx, transaction, accountID, peerID, svc); err != nil { + return err + } + + if err := m.ensureL4Port(ctx, transaction, svc); err != nil { + return err + } + + if err := m.checkPortConflict(ctx, transaction, svc); err != nil { + return err + } + + if err := validateTargetReferences(ctx, transaction, accountID, svc.Targets); err != nil { + return err + } + + if err := transaction.CreateService(ctx, svc); err != nil { + return fmt.Errorf("create service: %w", err) + } + + return nil + }) +} + +func (m *Manager) validateEphemeralPreconditions(ctx context.Context, transaction store.Store, accountID, peerID string, svc *service.Service) error { + // Lock the peer row to serialize concurrent creates for the same peer. + if _, err := transaction.GetPeerByID(ctx, store.LockingStrengthUpdate, accountID, peerID); err != nil { + return fmt.Errorf("lock peer row: %w", err) + } + + exists, err := transaction.EphemeralServiceExists(ctx, store.LockingStrengthUpdate, accountID, peerID, svc.Domain) + if err != nil { + return fmt.Errorf("check existing expose: %w", err) + } + if exists { + return status.Errorf(status.AlreadyExists, "peer already has an active expose session for this domain") + } + + if err := m.checkDomainAvailable(ctx, transaction, svc.Domain, ""); err != nil { + return err + } + + count, err := transaction.CountEphemeralServicesByPeer(ctx, store.LockingStrengthUpdate, accountID, peerID) + if err != nil { + return fmt.Errorf("count peer exposes: %w", err) + } + if count >= int64(maxExposesPerPeer) { + return status.Errorf(status.PreconditionFailed, "peer has reached the maximum number of active expose sessions (%d)", maxExposesPerPeer) + } + + return nil +} + +// checkDomainAvailable checks that no other service already uses this domain. +func (m *Manager) checkDomainAvailable(ctx context.Context, transaction store.Store, domain, excludeServiceID string) error { + existingService, err := transaction.GetServiceByDomain(ctx, domain) + if err != nil { + if sErr, ok := status.FromError(err); !ok || sErr.Type() != status.NotFound { + return fmt.Errorf("check existing service: %w", err) + } + return nil + } + + if existingService != nil && existingService.ID != excludeServiceID { + return status.Errorf(status.AlreadyExists, "domain already taken") + } + + return nil +} + +func (m *Manager) UpdateService(ctx context.Context, accountID, userID string, service *service.Service) (*service.Service, error) { + ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Update) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return nil, status.NewPermissionDeniedError() + } + + if err := service.Auth.HashSecrets(); err != nil { + return nil, fmt.Errorf("hash secrets: %w", err) + } + + updateInfo, err := m.persistServiceUpdate(ctx, accountID, service) + if err != nil { + return nil, err + } + + m.accountManager.StoreEvent(ctx, userID, service.ID, accountID, activity.ServiceUpdated, service.EventMeta()) + + if err := m.replaceHostByLookup(ctx, accountID, service); err != nil { + return nil, fmt.Errorf("failed to replace host by lookup for service %s: %w", service.ID, err) + } + + m.sendServiceUpdateNotifications(ctx, accountID, service, updateInfo) + m.accountManager.UpdateAccountPeers(ctx, accountID) + + return service, nil +} + +type serviceUpdateInfo struct { + oldCluster string + domainChanged bool + serviceEnabledChanged bool +} + +func (m *Manager) persistServiceUpdate(ctx context.Context, accountID string, service *service.Service) (*serviceUpdateInfo, error) { + var updateInfo serviceUpdateInfo + + err := m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + existingService, err := transaction.GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, service.ID) + if err != nil { + return err + } + + if err := validateProtocolChange(existingService.Mode, service.Mode); err != nil { + return err + } + + updateInfo.oldCluster = existingService.ProxyCluster + updateInfo.domainChanged = existingService.Domain != service.Domain + + if updateInfo.domainChanged { + if err := m.handleDomainChange(ctx, transaction, accountID, service); err != nil { + return err + } + } else { + service.ProxyCluster = existingService.ProxyCluster + } + + m.preserveExistingAuthSecrets(service, existingService) + if err := validateHeaderAuthValues(service.Auth.HeaderAuths); err != nil { + return err + } + m.preserveServiceMetadata(service, existingService) + m.preserveListenPort(service, existingService) + updateInfo.serviceEnabledChanged = existingService.Enabled != service.Enabled + + if err := m.ensureL4Port(ctx, transaction, service); err != nil { + return err + } + if err := m.checkPortConflict(ctx, transaction, service); err != nil { + return err + } + if err := validateTargetReferences(ctx, transaction, accountID, service.Targets); err != nil { + return err + } + if err := transaction.UpdateService(ctx, service); err != nil { + return fmt.Errorf("update service: %w", err) + } + + return nil + }) + + return &updateInfo, err +} + +func (m *Manager) handleDomainChange(ctx context.Context, transaction store.Store, accountID string, svc *service.Service) error { + if err := m.checkDomainAvailable(ctx, transaction, svc.Domain, svc.ID); err != nil { + return err + } + + if m.clusterDeriver != nil { + newCluster, err := m.clusterDeriver.DeriveClusterFromDomain(ctx, accountID, svc.Domain) + if err != nil { + log.WithError(err).Warnf("could not derive cluster from domain %s", svc.Domain) + } else { + svc.ProxyCluster = newCluster + } + } + + return nil +} + +// validateProtocolChange rejects mode changes on update. +// Only empty<->HTTP is allowed; all other transitions are rejected. +func validateProtocolChange(oldMode, newMode string) error { + if newMode == "" || newMode == oldMode { + return nil + } + if isHTTPFamily(oldMode) && isHTTPFamily(newMode) { + return nil + } + return status.Errorf(status.InvalidArgument, "cannot change mode from %q to %q", oldMode, newMode) +} + +func isHTTPFamily(mode string) bool { + return mode == "" || mode == "http" +} + +func (m *Manager) preserveExistingAuthSecrets(svc, existingService *service.Service) { + if svc.Auth.PasswordAuth != nil && svc.Auth.PasswordAuth.Enabled && + existingService.Auth.PasswordAuth != nil && existingService.Auth.PasswordAuth.Enabled && + svc.Auth.PasswordAuth.Password == "" { + svc.Auth.PasswordAuth = existingService.Auth.PasswordAuth + } + + if svc.Auth.PinAuth != nil && svc.Auth.PinAuth.Enabled && + existingService.Auth.PinAuth != nil && existingService.Auth.PinAuth.Enabled && + svc.Auth.PinAuth.Pin == "" { + svc.Auth.PinAuth = existingService.Auth.PinAuth + } + + preserveHeaderAuthHashes(svc.Auth.HeaderAuths, existingService.Auth.HeaderAuths) +} + +// preserveHeaderAuthHashes fills in empty header auth values from the existing +// service so that unchanged secrets are not lost on update. +func preserveHeaderAuthHashes(headers, existing []*service.HeaderAuthConfig) { + if len(headers) == 0 || len(existing) == 0 { + return + } + existingByHeader := make(map[string]string, len(existing)) + for _, h := range existing { + if h != nil && h.Value != "" { + existingByHeader[http.CanonicalHeaderKey(h.Header)] = h.Value + } + } + for _, h := range headers { + if h != nil && h.Enabled && h.Value == "" { + if hash, ok := existingByHeader[http.CanonicalHeaderKey(h.Header)]; ok { + h.Value = hash + } + } + } +} + +// validateHeaderAuthValues checks that all enabled header auths have a value +// (either freshly provided or preserved from the existing service). +func validateHeaderAuthValues(headers []*service.HeaderAuthConfig) error { + for i, h := range headers { + if h != nil && h.Enabled && h.Value == "" { + return status.Errorf(status.InvalidArgument, "header_auths[%d]: value is required", i) + } + } + return nil +} + +func (m *Manager) preserveServiceMetadata(service, existingService *service.Service) { + service.Meta = existingService.Meta + service.SessionPrivateKey = existingService.SessionPrivateKey + service.SessionPublicKey = existingService.SessionPublicKey +} + +func (m *Manager) preserveListenPort(svc, existing *service.Service) { + if existing.ListenPort > 0 && svc.ListenPort == 0 { + svc.ListenPort = existing.ListenPort + svc.PortAutoAssigned = existing.PortAutoAssigned + } +} + +func (m *Manager) sendServiceUpdateNotifications(ctx context.Context, accountID string, s *service.Service, updateInfo *serviceUpdateInfo) { + oidcCfg := m.proxyController.GetOIDCValidationConfig() + + switch { + case updateInfo.domainChanged || updateInfo.oldCluster != s.ProxyCluster: + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Delete, "", oidcCfg), updateInfo.oldCluster) + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Create, "", oidcCfg), s.ProxyCluster) + case !s.Enabled && updateInfo.serviceEnabledChanged: + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Delete, "", oidcCfg), s.ProxyCluster) + case s.Enabled && updateInfo.serviceEnabledChanged: + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Create, "", oidcCfg), s.ProxyCluster) + default: + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Update, "", oidcCfg), s.ProxyCluster) + } +} + +// validateTargetReferences checks that all target IDs reference existing peers or resources in the account. +func validateTargetReferences(ctx context.Context, transaction store.Store, accountID string, targets []*service.Target) error { + for _, target := range targets { + switch target.TargetType { + case service.TargetTypePeer: + if _, err := transaction.GetPeerByID(ctx, store.LockingStrengthShare, accountID, target.TargetId); err != nil { + if sErr, ok := status.FromError(err); ok && sErr.Type() == status.NotFound { + return status.Errorf(status.InvalidArgument, "peer target %q not found in account", target.TargetId) + } + return fmt.Errorf("look up peer target %q: %w", target.TargetId, err) + } + case service.TargetTypeHost, service.TargetTypeSubnet, service.TargetTypeDomain: + if _, err := transaction.GetNetworkResourceByID(ctx, store.LockingStrengthShare, accountID, target.TargetId); err != nil { + if sErr, ok := status.FromError(err); ok && sErr.Type() == status.NotFound { + return status.Errorf(status.InvalidArgument, "resource target %q not found in account", target.TargetId) + } + return fmt.Errorf("look up resource target %q: %w", target.TargetId, err) + } + default: + return status.Errorf(status.InvalidArgument, "unknown target type %q for target %q", target.TargetType, target.TargetId) + } + } + return nil +} + +func (m *Manager) DeleteService(ctx context.Context, accountID, userID, serviceID string) error { + ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete) + if err != nil { + return status.NewPermissionValidationError(err) + } + if !ok { + return status.NewPermissionDeniedError() + } + + var s *service.Service + err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + var err error + s, err = transaction.GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID) + if err != nil { + return err + } + + if err = transaction.DeleteServiceTargets(ctx, accountID, serviceID); err != nil { + return fmt.Errorf("failed to delete targets: %w", err) + } + + if err = transaction.DeleteService(ctx, accountID, serviceID); err != nil { + return fmt.Errorf("failed to delete service: %w", err) + } + + return nil + }) + if err != nil { + return err + } + + m.accountManager.StoreEvent(ctx, userID, serviceID, accountID, activity.ServiceDeleted, s.EventMeta()) + + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Delete, "", m.proxyController.GetOIDCValidationConfig()), s.ProxyCluster) + + m.accountManager.UpdateAccountPeers(ctx, accountID) + + return nil +} + +func (m *Manager) DeleteAllServices(ctx context.Context, accountID, userID string) error { + ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete) + if err != nil { + return status.NewPermissionValidationError(err) + } + if !ok { + return status.NewPermissionDeniedError() + } + + var services []*service.Service + err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + var err error + services, err = transaction.GetAccountServices(ctx, store.LockingStrengthUpdate, accountID) + if err != nil { + return err + } + + for _, svc := range services { + if err = transaction.DeleteService(ctx, accountID, svc.ID); err != nil { + return fmt.Errorf("failed to delete service: %w", err) + } + } + + return nil + }) + if err != nil { + return err + } + + oidcCfg := m.proxyController.GetOIDCValidationConfig() + + for _, svc := range services { + m.accountManager.StoreEvent(ctx, userID, svc.ID, accountID, activity.ServiceDeleted, svc.EventMeta()) + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, svc.ToProtoMapping(service.Delete, "", oidcCfg), svc.ProxyCluster) + } + + m.accountManager.UpdateAccountPeers(ctx, accountID) + + return nil +} + +// SetCertificateIssuedAt sets the certificate issued timestamp to the current time. +// Call this when receiving a gRPC notification that the certificate was issued. +func (m *Manager) SetCertificateIssuedAt(ctx context.Context, accountID, serviceID string) error { + return m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + service, err := transaction.GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID) + if err != nil { + return fmt.Errorf("failed to get service: %w", err) + } + + now := time.Now() + service.Meta.CertificateIssuedAt = &now + + if err = transaction.UpdateService(ctx, service); err != nil { + return fmt.Errorf("failed to update service certificate timestamp: %w", err) + } + + return nil + }) +} + +// SetStatus updates the status of the service (e.g., "active", "tunnel_not_created", etc.) +func (m *Manager) SetStatus(ctx context.Context, accountID, serviceID string, status service.Status) error { + return m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + service, err := transaction.GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID) + if err != nil { + return fmt.Errorf("failed to get service: %w", err) + } + + service.Meta.Status = string(status) + + if err = transaction.UpdateService(ctx, service); err != nil { + return fmt.Errorf("failed to update service status: %w", err) + } + + return nil + }) +} + +func (m *Manager) ReloadService(ctx context.Context, accountID, serviceID string) error { + s, err := m.store.GetServiceByID(ctx, store.LockingStrengthNone, accountID, serviceID) + if err != nil { + return fmt.Errorf("failed to get service: %w", err) + } + + err = m.replaceHostByLookup(ctx, accountID, s) + if err != nil { + return fmt.Errorf("failed to replace host by lookup for service %s: %w", s.ID, err) + } + + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Update, "", m.proxyController.GetOIDCValidationConfig()), s.ProxyCluster) + + m.accountManager.UpdateAccountPeers(ctx, accountID) + + return nil +} + +func (m *Manager) ReloadAllServicesForAccount(ctx context.Context, accountID string) error { + services, err := m.store.GetAccountServices(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return fmt.Errorf("failed to get services: %w", err) + } + + for _, s := range services { + err = m.replaceHostByLookup(ctx, accountID, s) + if err != nil { + return fmt.Errorf("failed to replace host by lookup for service %s: %w", s.ID, err) + } + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Update, "", m.proxyController.GetOIDCValidationConfig()), s.ProxyCluster) + } + + return nil +} + +func (m *Manager) GetGlobalServices(ctx context.Context) ([]*service.Service, error) { + services, err := m.store.GetServices(ctx, store.LockingStrengthNone) + if err != nil { + return nil, fmt.Errorf("failed to get services: %w", err) + } + + for _, service := range services { + err = m.replaceHostByLookup(ctx, service.AccountID, service) + if err != nil { + return nil, fmt.Errorf("failed to replace host by lookup for service %s: %w", service.ID, err) + } + } + + return services, nil +} + +func (m *Manager) GetServiceByID(ctx context.Context, accountID, serviceID string) (*service.Service, error) { + service, err := m.store.GetServiceByID(ctx, store.LockingStrengthNone, accountID, serviceID) + if err != nil { + return nil, fmt.Errorf("failed to get service: %w", err) + } + + err = m.replaceHostByLookup(ctx, accountID, service) + if err != nil { + return nil, fmt.Errorf("failed to replace host by lookup for service %s: %w", service.ID, err) + } + + return service, nil +} + +func (m *Manager) GetAccountServices(ctx context.Context, accountID string) ([]*service.Service, error) { + services, err := m.store.GetAccountServices(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return nil, fmt.Errorf("failed to get services: %w", err) + } + + for _, service := range services { + err = m.replaceHostByLookup(ctx, accountID, service) + if err != nil { + return nil, fmt.Errorf("failed to replace host by lookup for service %s: %w", service.ID, err) + } + } + + return services, nil +} + +func (m *Manager) GetServiceIDByTargetID(ctx context.Context, accountID string, resourceID string) (string, error) { + target, err := m.store.GetServiceTargetByTargetID(ctx, store.LockingStrengthNone, accountID, resourceID) + if err != nil { + if s, ok := status.FromError(err); ok && s.Type() == status.NotFound { + return "", nil + } + return "", fmt.Errorf("failed to get service target by resource ID: %w", err) + } + + if target == nil { + return "", nil + } + + return target.ServiceID, nil +} + +// validateExposePermission checks whether the peer is allowed to use the expose feature. +// It verifies the account has peer expose enabled and that the peer belongs to an allowed group. +func (m *Manager) validateExposePermission(ctx context.Context, accountID, peerID string) error { + settings, err := m.store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) + if err != nil { + log.WithContext(ctx).Errorf("failed to get account settings: %v", err) + return status.Errorf(status.Internal, "get account settings: %v", err) + } + + if !settings.PeerExposeEnabled { + return status.Errorf(status.PermissionDenied, "peer expose is not enabled for this account") + } + + if len(settings.PeerExposeGroups) == 0 { + return status.Errorf(status.PermissionDenied, "no group is set for peer expose") + } + + peerGroupIDs, err := m.store.GetPeerGroupIDs(ctx, store.LockingStrengthNone, accountID, peerID) + if err != nil { + log.WithContext(ctx).Errorf("failed to get peer group IDs: %v", err) + return status.Errorf(status.Internal, "get peer groups: %v", err) + } + + for _, pg := range peerGroupIDs { + if slices.Contains(settings.PeerExposeGroups, pg) { + return nil + } + } + + return status.Errorf(status.PermissionDenied, "peer is not in an allowed expose group") +} + +func (m *Manager) resolveDefaultDomain(serviceName string) (string, error) { + return m.buildRandomDomain(serviceName) +} + +// CreateServiceFromPeer creates a service initiated by a peer expose request. +// It validates the request, checks expose permissions, enforces the per-peer limit, +// creates the service, and tracks it for TTL-based reaping. +func (m *Manager) CreateServiceFromPeer(ctx context.Context, accountID, peerID string, req *service.ExposeServiceRequest) (*service.ExposeServiceResponse, error) { + if err := req.Validate(); err != nil { + return nil, status.Errorf(status.InvalidArgument, "validate expose request: %v", err) + } + + if err := m.validateExposePermission(ctx, accountID, peerID); err != nil { + return nil, err + } + + serviceName, err := service.GenerateExposeName(req.NamePrefix) + if err != nil { + return nil, status.Errorf(status.InvalidArgument, "generate service name: %v", err) + } + + svc := req.ToService(accountID, peerID, serviceName) + svc.Source = service.SourceEphemeral + + if svc.Domain == "" { + domain, err := m.resolveDefaultDomain(svc.Name) + if err != nil { + return nil, err + } + svc.Domain = domain + } + + if svc.Auth.BearerAuth != nil && svc.Auth.BearerAuth.Enabled { + groupIDs, err := m.getGroupIDsFromNames(ctx, accountID, svc.Auth.BearerAuth.DistributionGroups) + if err != nil { + return nil, fmt.Errorf("get group ids for service %s: %w", svc.Name, err) + } + svc.Auth.BearerAuth.DistributionGroups = groupIDs + } + + if err := m.initializeServiceForCreate(ctx, accountID, svc); err != nil { + return nil, err + } + + peer, err := m.store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID) + if err != nil { + return nil, err + } + + svc.SourcePeer = peerID + + now := time.Now() + svc.Meta.LastRenewedAt = &now + + if err := m.persistNewEphemeralService(ctx, accountID, peerID, svc); err != nil { + return nil, err + } + + meta := addPeerInfoToEventMeta(svc.EventMeta(), peer) + m.accountManager.StoreEvent(ctx, peerID, svc.ID, accountID, activity.PeerServiceExposed, meta) + + if err := m.replaceHostByLookup(ctx, accountID, svc); err != nil { + return nil, fmt.Errorf("replace host by lookup for service %s: %w", svc.ID, err) + } + + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, svc.ToProtoMapping(service.Create, "", m.proxyController.GetOIDCValidationConfig()), svc.ProxyCluster) + m.accountManager.UpdateAccountPeers(ctx, accountID) + + serviceURL := "https://" + svc.Domain + if service.IsL4Protocol(svc.Mode) { + serviceURL = fmt.Sprintf("%s://%s:%d", svc.Mode, svc.Domain, svc.ListenPort) + } + + return &service.ExposeServiceResponse{ + ServiceName: svc.Name, + ServiceURL: serviceURL, + Domain: svc.Domain, + PortAutoAssigned: svc.PortAutoAssigned, + }, nil +} + +func (m *Manager) getGroupIDsFromNames(ctx context.Context, accountID string, groupNames []string) ([]string, error) { + if len(groupNames) == 0 { + return []string{}, fmt.Errorf("no group names provided") + } + groupIDs := make([]string, 0, len(groupNames)) + for _, groupName := range groupNames { + g, err := m.accountManager.GetGroupByName(ctx, groupName, accountID) + if err != nil { + return nil, fmt.Errorf("failed to get group by name %s: %w", groupName, err) + } + groupIDs = append(groupIDs, g.ID) + } + return groupIDs, nil +} + +func (m *Manager) getDefaultClusterDomain() (string, error) { + if m.clusterDeriver == nil { + return "", fmt.Errorf("unable to get cluster domain") + } + clusterDomains := m.clusterDeriver.GetClusterDomains() + if len(clusterDomains) == 0 { + return "", fmt.Errorf("no cluster domains available") + } + return clusterDomains[rand.IntN(len(clusterDomains))], nil +} + +func (m *Manager) buildRandomDomain(name string) (string, error) { + domain, err := m.getDefaultClusterDomain() + if err != nil { + return "", err + } + return name + "." + domain, nil +} + +// RenewServiceFromPeer updates the DB timestamp for the peer's ephemeral service. +func (m *Manager) RenewServiceFromPeer(ctx context.Context, accountID, peerID, serviceID string) error { + return m.store.RenewEphemeralService(ctx, accountID, peerID, serviceID) +} + +// StopServiceFromPeer stops a peer's active expose session by deleting the service from the DB. +func (m *Manager) StopServiceFromPeer(ctx context.Context, accountID, peerID, serviceID string) error { + if err := m.deleteServiceFromPeer(ctx, accountID, peerID, serviceID, false); err != nil { + log.WithContext(ctx).Errorf("failed to delete peer-exposed service %s: %v", serviceID, err) + return err + } + return nil +} + +// deleteServiceFromPeer deletes a peer-initiated service identified by service ID. +// When expired is true, the activity is recorded as PeerServiceExposeExpired instead of PeerServiceUnexposed. +func (m *Manager) deleteServiceFromPeer(ctx context.Context, accountID, peerID, serviceID string, expired bool) error { + activityCode := activity.PeerServiceUnexposed + if expired { + activityCode = activity.PeerServiceExposeExpired + } + return m.deletePeerService(ctx, accountID, peerID, serviceID, activityCode) +} + +func (m *Manager) deletePeerService(ctx context.Context, accountID, peerID, serviceID string, activityCode activity.Activity) error { + var svc *service.Service + err := m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + var err error + svc, err = transaction.GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID) + if err != nil { + return err + } + + if svc.Source != service.SourceEphemeral { + return status.Errorf(status.PermissionDenied, "cannot delete API-created service via peer expose") + } + + if svc.SourcePeer != peerID { + return status.Errorf(status.PermissionDenied, "cannot delete service exposed by another peer") + } + + if err = transaction.DeleteService(ctx, accountID, serviceID); err != nil { + return fmt.Errorf("delete service: %w", err) + } + + return nil + }) + if err != nil { + return err + } + + peer, err := m.store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID) + if err != nil { + log.WithContext(ctx).Debugf("failed to get peer %s for event metadata: %v", peerID, err) + peer = nil + } + + meta := addPeerInfoToEventMeta(svc.EventMeta(), peer) + + m.accountManager.StoreEvent(ctx, peerID, serviceID, accountID, activityCode, meta) + + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, svc.ToProtoMapping(service.Delete, "", m.proxyController.GetOIDCValidationConfig()), svc.ProxyCluster) + + m.accountManager.UpdateAccountPeers(ctx, accountID) + + return nil +} + +// deleteExpiredPeerService deletes an ephemeral service by ID after re-checking +// that it is still expired under a row lock. This prevents deleting a service +// that was renewed between the batch query and this delete, and ensures only one +// management instance processes the deletion +func (m *Manager) deleteExpiredPeerService(ctx context.Context, accountID, peerID, serviceID string) error { + var svc *service.Service + deleted := false + err := m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + var err error + svc, err = transaction.GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID) + if err != nil { + return err + } + + if svc.Source != service.SourceEphemeral || svc.SourcePeer != peerID { + return status.Errorf(status.PermissionDenied, "service does not match expected ephemeral owner") + } + + if svc.Meta.LastRenewedAt != nil && time.Since(*svc.Meta.LastRenewedAt) <= exposeTTL { + return nil + } + + if err = transaction.DeleteService(ctx, accountID, serviceID); err != nil { + return fmt.Errorf("delete service: %w", err) + } + deleted = true + + return nil + }) + if err != nil { + return err + } + + if !deleted { + return nil + } + + peer, err := m.store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID) + if err != nil { + log.WithContext(ctx).Debugf("failed to get peer %s for event metadata: %v", peerID, err) + peer = nil + } + + meta := addPeerInfoToEventMeta(svc.EventMeta(), peer) + m.accountManager.StoreEvent(ctx, peerID, serviceID, accountID, activity.PeerServiceExposeExpired, meta) + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, svc.ToProtoMapping(service.Delete, "", m.proxyController.GetOIDCValidationConfig()), svc.ProxyCluster) + m.accountManager.UpdateAccountPeers(ctx, accountID) + + return nil +} + +func addPeerInfoToEventMeta(meta map[string]any, peer *nbpeer.Peer) map[string]any { + if peer == nil { + return meta + } + meta["peer_name"] = peer.Name + if peer.IP != nil { + meta["peer_ip"] = peer.IP.String() + } + return meta +} diff --git a/management/internals/modules/reverseproxy/service/manager/manager_test.go b/management/internals/modules/reverseproxy/service/manager/manager_test.go new file mode 100644 index 000000000..d23c91017 --- /dev/null +++ b/management/internals/modules/reverseproxy/service/manager/manager_test.go @@ -0,0 +1,1216 @@ +package manager + +import ( + "context" + "errors" + "net" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/metric/noop" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + "github.com/netbirdio/netbird/management/server/account" + "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/mock_server" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/permissions/modules" + "github.com/netbirdio/netbird/management/server/permissions/operations" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +func TestInitializeServiceForCreate(t *testing.T) { + ctx := context.Background() + accountID := "test-account" + + t.Run("successful initialization without cluster deriver", func(t *testing.T) { + mgr := &Manager{ + clusterDeriver: nil, + } + + service := &rpservice.Service{ + Domain: "example.com", + Auth: rpservice.AuthConfig{}, + } + + err := mgr.initializeServiceForCreate(ctx, accountID, service) + + assert.NoError(t, err) + assert.Equal(t, accountID, service.AccountID) + assert.Empty(t, service.ProxyCluster, "proxy cluster should be empty when no deriver") + assert.NotEmpty(t, service.ID, "service ID should be initialized") + assert.NotEmpty(t, service.SessionPrivateKey, "session private key should be generated") + assert.NotEmpty(t, service.SessionPublicKey, "session public key should be generated") + }) + + t.Run("verifies session keys are different", func(t *testing.T) { + mgr := &Manager{ + clusterDeriver: nil, + } + + service1 := &rpservice.Service{Domain: "test1.com", Auth: rpservice.AuthConfig{}} + service2 := &rpservice.Service{Domain: "test2.com", Auth: rpservice.AuthConfig{}} + + err1 := mgr.initializeServiceForCreate(ctx, accountID, service1) + err2 := mgr.initializeServiceForCreate(ctx, accountID, service2) + + assert.NoError(t, err1) + assert.NoError(t, err2) + assert.NotEqual(t, service1.SessionPrivateKey, service2.SessionPrivateKey, "private keys should be unique") + assert.NotEqual(t, service1.SessionPublicKey, service2.SessionPublicKey, "public keys should be unique") + }) +} + +func TestCheckDomainAvailable(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + domain string + excludeServiceID string + setupMock func(*store.MockStore) + expectedError bool + errorType status.Type + }{ + { + name: "domain available - not found", + domain: "available.com", + excludeServiceID: "", + setupMock: func(ms *store.MockStore) { + ms.EXPECT(). + GetServiceByDomain(ctx, "available.com"). + Return(nil, status.Errorf(status.NotFound, "not found")) + }, + expectedError: false, + }, + { + name: "domain already exists", + domain: "exists.com", + excludeServiceID: "", + setupMock: func(ms *store.MockStore) { + ms.EXPECT(). + GetServiceByDomain(ctx, "exists.com"). + Return(&rpservice.Service{ID: "existing-id", Domain: "exists.com"}, nil) + }, + expectedError: true, + errorType: status.AlreadyExists, + }, + { + name: "domain exists but excluded (same ID)", + domain: "exists.com", + excludeServiceID: "service-123", + setupMock: func(ms *store.MockStore) { + ms.EXPECT(). + GetServiceByDomain(ctx, "exists.com"). + Return(&rpservice.Service{ID: "service-123", Domain: "exists.com"}, nil) + }, + expectedError: false, + }, + { + name: "domain exists with different ID", + domain: "exists.com", + excludeServiceID: "service-456", + setupMock: func(ms *store.MockStore) { + ms.EXPECT(). + GetServiceByDomain(ctx, "exists.com"). + Return(&rpservice.Service{ID: "service-123", Domain: "exists.com"}, nil) + }, + expectedError: true, + errorType: status.AlreadyExists, + }, + { + name: "store error (non-NotFound)", + domain: "error.com", + excludeServiceID: "", + setupMock: func(ms *store.MockStore) { + ms.EXPECT(). + GetServiceByDomain(ctx, "error.com"). + Return(nil, errors.New("database error")) + }, + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + tt.setupMock(mockStore) + + mgr := &Manager{} + err := mgr.checkDomainAvailable(ctx, mockStore, tt.domain, tt.excludeServiceID) + + if tt.expectedError { + require.Error(t, err) + if tt.errorType != 0 { + sErr, ok := status.FromError(err) + require.True(t, ok, "error should be a status error") + assert.Equal(t, tt.errorType, sErr.Type()) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestCheckDomainAvailable_EdgeCases(t *testing.T) { + ctx := context.Background() + + t.Run("empty domain", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT(). + GetServiceByDomain(ctx, ""). + Return(nil, status.Errorf(status.NotFound, "not found")) + + mgr := &Manager{} + err := mgr.checkDomainAvailable(ctx, mockStore, "", "") + + assert.NoError(t, err) + }) + + t.Run("empty exclude ID with existing service", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT(). + GetServiceByDomain(ctx, "test.com"). + Return(&rpservice.Service{ID: "some-id", Domain: "test.com"}, nil) + + mgr := &Manager{} + err := mgr.checkDomainAvailable(ctx, mockStore, "test.com", "") + + assert.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.AlreadyExists, sErr.Type()) + }) + + t.Run("nil existing service with nil error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT(). + GetServiceByDomain(ctx, "nil.com"). + Return(nil, nil) + + mgr := &Manager{} + err := mgr.checkDomainAvailable(ctx, mockStore, "nil.com", "") + + assert.NoError(t, err) + }) +} + +func TestPersistNewService(t *testing.T) { + ctx := context.Background() + accountID := "test-account" + + t.Run("successful service creation with no targets", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + service := &rpservice.Service{ + ID: "service-123", + Domain: "new.com", + Targets: []*rpservice.Target{}, + } + + // Mock ExecuteInTransaction to execute the function immediately + mockStore.EXPECT(). + ExecuteInTransaction(ctx, gomock.Any()). + DoAndReturn(func(ctx context.Context, fn func(store.Store) error) error { + // Create another mock for the transaction + txMock := store.NewMockStore(ctrl) + txMock.EXPECT(). + GetServiceByDomain(ctx, "new.com"). + Return(nil, status.Errorf(status.NotFound, "not found")) + txMock.EXPECT(). + CreateService(ctx, service). + Return(nil) + + return fn(txMock) + }) + + mgr := &Manager{store: mockStore} + err := mgr.persistNewService(ctx, accountID, service) + + assert.NoError(t, err) + }) + + t.Run("domain already exists", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + service := &rpservice.Service{ + ID: "service-123", + Domain: "existing.com", + Targets: []*rpservice.Target{}, + } + + mockStore.EXPECT(). + ExecuteInTransaction(ctx, gomock.Any()). + DoAndReturn(func(ctx context.Context, fn func(store.Store) error) error { + txMock := store.NewMockStore(ctrl) + txMock.EXPECT(). + GetServiceByDomain(ctx, "existing.com"). + Return(&rpservice.Service{ID: "other-id", Domain: "existing.com"}, nil) + + return fn(txMock) + }) + + mgr := &Manager{store: mockStore} + err := mgr.persistNewService(ctx, accountID, service) + + require.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.AlreadyExists, sErr.Type()) + }) +} +func TestPreserveExistingAuthSecrets(t *testing.T) { + mgr := &Manager{} + + t.Run("preserve password when empty", func(t *testing.T) { + existing := &rpservice.Service{ + Auth: rpservice.AuthConfig{ + PasswordAuth: &rpservice.PasswordAuthConfig{ + Enabled: true, + Password: "hashed-password", + }, + }, + } + + updated := &rpservice.Service{ + Auth: rpservice.AuthConfig{ + PasswordAuth: &rpservice.PasswordAuthConfig{ + Enabled: true, + Password: "", + }, + }, + } + + mgr.preserveExistingAuthSecrets(updated, existing) + + assert.Equal(t, existing.Auth.PasswordAuth, updated.Auth.PasswordAuth) + }) + + t.Run("preserve pin when empty", func(t *testing.T) { + existing := &rpservice.Service{ + Auth: rpservice.AuthConfig{ + PinAuth: &rpservice.PINAuthConfig{ + Enabled: true, + Pin: "hashed-pin", + }, + }, + } + + updated := &rpservice.Service{ + Auth: rpservice.AuthConfig{ + PinAuth: &rpservice.PINAuthConfig{ + Enabled: true, + Pin: "", + }, + }, + } + + mgr.preserveExistingAuthSecrets(updated, existing) + + assert.Equal(t, existing.Auth.PinAuth, updated.Auth.PinAuth) + }) + + t.Run("do not preserve when password is provided", func(t *testing.T) { + existing := &rpservice.Service{ + Auth: rpservice.AuthConfig{ + PasswordAuth: &rpservice.PasswordAuthConfig{ + Enabled: true, + Password: "old-password", + }, + }, + } + + updated := &rpservice.Service{ + Auth: rpservice.AuthConfig{ + PasswordAuth: &rpservice.PasswordAuthConfig{ + Enabled: true, + Password: "new-password", + }, + }, + } + + mgr.preserveExistingAuthSecrets(updated, existing) + + assert.Equal(t, "new-password", updated.Auth.PasswordAuth.Password) + assert.NotEqual(t, existing.Auth.PasswordAuth, updated.Auth.PasswordAuth) + }) +} + +func TestPreserveServiceMetadata(t *testing.T) { + mgr := &Manager{} + + existing := &rpservice.Service{ + Meta: rpservice.Meta{ + CertificateIssuedAt: func() *time.Time { t := time.Now(); return &t }(), + Status: "active", + }, + SessionPrivateKey: "private-key", + SessionPublicKey: "public-key", + } + + updated := &rpservice.Service{ + Domain: "updated.com", + } + + mgr.preserveServiceMetadata(updated, existing) + + assert.Equal(t, existing.Meta, updated.Meta) + assert.Equal(t, existing.SessionPrivateKey, updated.SessionPrivateKey) + assert.Equal(t, existing.SessionPublicKey, updated.SessionPublicKey) +} + +func TestDeletePeerService_SourcePeerValidation(t *testing.T) { + ctx := context.Background() + accountID := "test-account" + ownerPeerID := "peer-owner" + otherPeerID := "peer-other" + serviceID := "service-123" + + testPeer := &nbpeer.Peer{ + ID: ownerPeerID, + Name: "test-peer", + IP: net.ParseIP("100.64.0.1"), + } + + newEphemeralService := func() *rpservice.Service { + return &rpservice.Service{ + ID: serviceID, + AccountID: accountID, + Name: "test-service", + Domain: "test.example.com", + Source: rpservice.SourceEphemeral, + SourcePeer: ownerPeerID, + } + } + + newPermanentService := func() *rpservice.Service { + return &rpservice.Service{ + ID: serviceID, + AccountID: accountID, + Name: "api-service", + Domain: "api.example.com", + Source: rpservice.SourcePermanent, + } + } + + newProxyServer := func(t *testing.T) *nbgrpc.ProxyServiceServer { + t.Helper() + tokenStore, err := nbgrpc.NewOneTimeTokenStore(context.Background(), 1*time.Hour, 10*time.Minute, 100) + require.NoError(t, err) + pkceStore, err := nbgrpc.NewPKCEVerifierStore(context.Background(), 10*time.Minute, 10*time.Minute, 100) + require.NoError(t, err) + srv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil) + return srv + } + + t.Run("owner peer can delete own service", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + var storedActivity activity.Activity + mockStore := store.NewMockStore(ctrl) + mockAccountMgr := &mock_server.MockAccountManager{ + StoreEventFunc: func(_ context.Context, _, _, _ string, activityID activity.ActivityDescriber, _ map[string]any) { + storedActivity = activityID.(activity.Activity) + }, + UpdateAccountPeersFunc: func(_ context.Context, _ string) {}, + } + + mockStore.EXPECT(). + ExecuteInTransaction(ctx, gomock.Any()). + DoAndReturn(func(ctx context.Context, fn func(store.Store) error) error { + txMock := store.NewMockStore(ctrl) + txMock.EXPECT(). + GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID). + Return(newEphemeralService(), nil) + txMock.EXPECT(). + DeleteService(ctx, accountID, serviceID). + Return(nil) + return fn(txMock) + }) + mockStore.EXPECT(). + GetPeerByID(ctx, store.LockingStrengthNone, accountID, ownerPeerID). + Return(testPeer, nil) + + mgr := &Manager{ + store: mockStore, + accountManager: mockAccountMgr, + proxyController: func() proxy.Controller { + c, err := proxymanager.NewGRPCController(newProxyServer(t), noop.NewMeterProvider().Meter("")) + require.NoError(t, err) + return c + }(), + } + + err := mgr.deletePeerService(ctx, accountID, ownerPeerID, serviceID, activity.PeerServiceUnexposed) + require.NoError(t, err) + assert.Equal(t, activity.PeerServiceUnexposed, storedActivity, "should store unexposed activity") + }) + + t.Run("different peer cannot delete service", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + + mockStore.EXPECT(). + ExecuteInTransaction(ctx, gomock.Any()). + DoAndReturn(func(ctx context.Context, fn func(store.Store) error) error { + txMock := store.NewMockStore(ctrl) + txMock.EXPECT(). + GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID). + Return(newEphemeralService(), nil) + return fn(txMock) + }) + + mgr := &Manager{ + store: mockStore, + } + + err := mgr.deletePeerService(ctx, accountID, otherPeerID, serviceID, activity.PeerServiceUnexposed) + require.Error(t, err) + + sErr, ok := status.FromError(err) + require.True(t, ok, "should be a status error") + assert.Equal(t, status.PermissionDenied, sErr.Type(), "should be permission denied") + assert.Contains(t, err.Error(), "another peer") + }) + + t.Run("cannot delete API-created service", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + + mockStore.EXPECT(). + ExecuteInTransaction(ctx, gomock.Any()). + DoAndReturn(func(ctx context.Context, fn func(store.Store) error) error { + txMock := store.NewMockStore(ctrl) + txMock.EXPECT(). + GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID). + Return(newPermanentService(), nil) + return fn(txMock) + }) + + mgr := &Manager{ + store: mockStore, + } + + err := mgr.deletePeerService(ctx, accountID, ownerPeerID, serviceID, activity.PeerServiceUnexposed) + require.Error(t, err) + + sErr, ok := status.FromError(err) + require.True(t, ok, "should be a status error") + assert.Equal(t, status.PermissionDenied, sErr.Type(), "should be permission denied") + assert.Contains(t, err.Error(), "API-created") + }) + + t.Run("expire uses correct activity code", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + var storedActivity activity.Activity + mockStore := store.NewMockStore(ctrl) + mockAccountMgr := &mock_server.MockAccountManager{ + StoreEventFunc: func(_ context.Context, _, _, _ string, activityID activity.ActivityDescriber, _ map[string]any) { + storedActivity = activityID.(activity.Activity) + }, + UpdateAccountPeersFunc: func(_ context.Context, _ string) {}, + } + + mockStore.EXPECT(). + ExecuteInTransaction(ctx, gomock.Any()). + DoAndReturn(func(ctx context.Context, fn func(store.Store) error) error { + txMock := store.NewMockStore(ctrl) + txMock.EXPECT(). + GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID). + Return(newEphemeralService(), nil) + txMock.EXPECT(). + DeleteService(ctx, accountID, serviceID). + Return(nil) + return fn(txMock) + }) + mockStore.EXPECT(). + GetPeerByID(ctx, store.LockingStrengthNone, accountID, ownerPeerID). + Return(testPeer, nil) + + mgr := &Manager{ + store: mockStore, + accountManager: mockAccountMgr, + proxyController: func() proxy.Controller { + c, err := proxymanager.NewGRPCController(newProxyServer(t), noop.NewMeterProvider().Meter("")) + require.NoError(t, err) + return c + }(), + } + + err := mgr.deletePeerService(ctx, accountID, ownerPeerID, serviceID, activity.PeerServiceExposeExpired) + require.NoError(t, err) + assert.Equal(t, activity.PeerServiceExposeExpired, storedActivity, "should store expired activity") + }) + + t.Run("event meta includes peer info", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + var storedMeta map[string]any + mockStore := store.NewMockStore(ctrl) + mockAccountMgr := &mock_server.MockAccountManager{ + StoreEventFunc: func(_ context.Context, _, _, _ string, _ activity.ActivityDescriber, meta map[string]any) { + storedMeta = meta + }, + UpdateAccountPeersFunc: func(_ context.Context, _ string) {}, + } + + mockStore.EXPECT(). + ExecuteInTransaction(ctx, gomock.Any()). + DoAndReturn(func(ctx context.Context, fn func(store.Store) error) error { + txMock := store.NewMockStore(ctrl) + txMock.EXPECT(). + GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID). + Return(newEphemeralService(), nil) + txMock.EXPECT(). + DeleteService(ctx, accountID, serviceID). + Return(nil) + return fn(txMock) + }) + mockStore.EXPECT(). + GetPeerByID(ctx, store.LockingStrengthNone, accountID, ownerPeerID). + Return(testPeer, nil) + + mgr := &Manager{ + store: mockStore, + accountManager: mockAccountMgr, + proxyController: func() proxy.Controller { + c, err := proxymanager.NewGRPCController(newProxyServer(t), noop.NewMeterProvider().Meter("")) + require.NoError(t, err) + return c + }(), + } + + err := mgr.deletePeerService(ctx, accountID, ownerPeerID, serviceID, activity.PeerServiceUnexposed) + require.NoError(t, err) + require.NotNil(t, storedMeta) + assert.Equal(t, "test-peer", storedMeta["peer_name"], "meta should contain peer name") + assert.Equal(t, "100.64.0.1", storedMeta["peer_ip"], "meta should contain peer IP") + assert.Equal(t, "test-service", storedMeta["name"], "meta should contain service name") + assert.Equal(t, "test.example.com", storedMeta["domain"], "meta should contain service domain") + }) +} + +// testClusterDeriver is a minimal ClusterDeriver that returns a fixed domain list. +type testClusterDeriver struct { + domains []string +} + +func (d *testClusterDeriver) DeriveClusterFromDomain(_ context.Context, _, domain string) (string, error) { + return "test-cluster", nil +} + +func (d *testClusterDeriver) GetClusterDomains() []string { + return d.domains +} + +const ( + testAccountID = "test-account" + testPeerID = "test-peer-1" + testGroupID = "test-group-1" + testUserID = "test-user" +) + +// setupIntegrationTest creates a real SQLite store with seeded test data for integration tests. +func setupIntegrationTest(t *testing.T) (*Manager, store.Store) { + t.Helper() + + ctx := context.Background() + testStore, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err) + t.Cleanup(cleanup) + + err = testStore.SaveAccount(ctx, &types.Account{ + Id: testAccountID, + CreatedBy: testUserID, + Settings: &types.Settings{ + PeerExposeEnabled: true, + PeerExposeGroups: []string{testGroupID}, + }, + Users: map[string]*types.User{ + testUserID: { + Id: testUserID, + AccountID: testAccountID, + Role: types.UserRoleAdmin, + }, + }, + Peers: map[string]*nbpeer.Peer{ + testPeerID: { + ID: testPeerID, + AccountID: testAccountID, + Key: "test-key", + DNSLabel: "test-peer", + Name: "test-peer", + IP: net.ParseIP("100.64.0.1"), + Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()}, + Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"}, + }, + }, + Groups: map[string]*types.Group{ + testGroupID: { + ID: testGroupID, + AccountID: testAccountID, + Name: "Expose Group", + }, + }, + }) + require.NoError(t, err) + + err = testStore.AddPeerToGroup(ctx, testAccountID, testPeerID, testGroupID) + require.NoError(t, err) + + permsMgr := permissions.NewManager(testStore) + + accountMgr := &mock_server.MockAccountManager{ + StoreEventFunc: func(_ context.Context, _, _, _ string, _ activity.ActivityDescriber, _ map[string]any) {}, + UpdateAccountPeersFunc: func(_ context.Context, _ string) {}, + GetGroupByNameFunc: func(ctx context.Context, accountID, groupName string) (*types.Group, error) { + return testStore.GetGroupByName(ctx, store.LockingStrengthNone, groupName, accountID) + }, + } + + tokenStore, err := nbgrpc.NewOneTimeTokenStore(ctx, 1*time.Hour, 10*time.Minute, 100) + require.NoError(t, err) + pkceStore, err := nbgrpc.NewPKCEVerifierStore(ctx, 10*time.Minute, 10*time.Minute, 100) + require.NoError(t, err) + proxySrv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil) + + proxyController, err := proxymanager.NewGRPCController(proxySrv, noop.NewMeterProvider().Meter("")) + require.NoError(t, err) + + mgr := &Manager{ + store: testStore, + accountManager: accountMgr, + permissionsManager: permsMgr, + proxyController: proxyController, + clusterDeriver: &testClusterDeriver{ + domains: []string{"test.netbird.io"}, + }, + } + mgr.exposeReaper = &exposeReaper{manager: mgr} + + return mgr, testStore +} + +func Test_validateExposePermission(t *testing.T) { + ctx := context.Background() + + t.Run("allowed when peer is in expose group", func(t *testing.T) { + mgr, _ := setupIntegrationTest(t) + err := mgr.validateExposePermission(ctx, testAccountID, testPeerID) + assert.NoError(t, err) + }) + + t.Run("denied when peer is not in expose group", func(t *testing.T) { + mgr, testStore := setupIntegrationTest(t) + + // Add a peer that is NOT in the expose group + otherPeerID := "other-peer" + err := testStore.AddPeerToAccount(ctx, &nbpeer.Peer{ + ID: otherPeerID, + AccountID: testAccountID, + Key: "other-key", + DNSLabel: "other-peer", + Name: "other-peer", + IP: net.ParseIP("100.64.0.2"), + Status: &nbpeer.PeerStatus{LastSeen: time.Now()}, + Meta: nbpeer.PeerSystemMeta{Hostname: "other-peer"}, + }) + require.NoError(t, err) + + err = mgr.validateExposePermission(ctx, testAccountID, otherPeerID) + require.Error(t, err) + assert.Contains(t, err.Error(), "not in an allowed expose group") + }) + + t.Run("denied when expose is disabled", func(t *testing.T) { + mgr, testStore := setupIntegrationTest(t) + + // Disable peer expose + s, err := testStore.GetAccountSettings(ctx, store.LockingStrengthNone, testAccountID) + require.NoError(t, err) + s.PeerExposeEnabled = false + err = testStore.SaveAccountSettings(ctx, testAccountID, s) + require.NoError(t, err) + + err = mgr.validateExposePermission(ctx, testAccountID, testPeerID) + require.Error(t, err) + assert.Contains(t, err.Error(), "not enabled") + }) + + t.Run("disallowed when no groups configured", func(t *testing.T) { + mgr, testStore := setupIntegrationTest(t) + + // Enable expose with empty groups — no groups configured means no peer is allowed + s, err := testStore.GetAccountSettings(ctx, store.LockingStrengthNone, testAccountID) + require.NoError(t, err) + s.PeerExposeGroups = []string{} + err = testStore.SaveAccountSettings(ctx, testAccountID, s) + require.NoError(t, err) + + err = mgr.validateExposePermission(ctx, testAccountID, testPeerID) + assert.Error(t, err) + }) + + t.Run("error when store returns error", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT().GetAccountSettings(gomock.Any(), gomock.Any(), testAccountID).Return(nil, errors.New("store error")) + mgr := &Manager{store: mockStore} + err := mgr.validateExposePermission(ctx, testAccountID, testPeerID) + require.Error(t, err) + assert.Contains(t, err.Error(), "get account settings") + }) +} + +func TestCreateServiceFromPeer(t *testing.T) { + ctx := context.Background() + + t.Run("creates service with random domain", func(t *testing.T) { + mgr, testStore := setupIntegrationTest(t) + + req := &rpservice.ExposeServiceRequest{ + Port: 8080, + Mode: "http", + } + + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, req) + require.NoError(t, err) + assert.NotEmpty(t, resp.ServiceName, "service name should be generated") + assert.Contains(t, resp.Domain, "test.netbird.io", "domain should use cluster domain") + assert.NotEmpty(t, resp.ServiceURL, "service URL should be set") + + // Verify service is persisted in store + persisted, err := testStore.GetServiceByDomain(ctx, resp.Domain) + require.NoError(t, err) + assert.Equal(t, resp.Domain, persisted.Domain) + assert.Equal(t, rpservice.SourceEphemeral, persisted.Source, "source should be ephemeral") + assert.Equal(t, testPeerID, persisted.SourcePeer, "source peer should be set") + assert.NotNil(t, persisted.Meta.LastRenewedAt, "last renewed should be set") + }) + + t.Run("creates service with custom domain", func(t *testing.T) { + mgr, _ := setupIntegrationTest(t) + + req := &rpservice.ExposeServiceRequest{ + Port: 80, + Mode: "http", + Domain: "example.com", + } + + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, req) + require.NoError(t, err) + assert.Contains(t, resp.Domain, "example.com", "should use the provided domain") + }) + + t.Run("validates expose permission internally", func(t *testing.T) { + mgr, testStore := setupIntegrationTest(t) + + // Disable peer expose + s, err := testStore.GetAccountSettings(ctx, store.LockingStrengthNone, testAccountID) + require.NoError(t, err) + s.PeerExposeEnabled = false + err = testStore.SaveAccountSettings(ctx, testAccountID, s) + require.NoError(t, err) + + req := &rpservice.ExposeServiceRequest{ + Port: 8080, + Mode: "http", + } + + _, err = mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, req) + require.Error(t, err) + assert.Contains(t, err.Error(), "not enabled") + }) + + t.Run("validates request fields", func(t *testing.T) { + mgr, _ := setupIntegrationTest(t) + + req := &rpservice.ExposeServiceRequest{ + Port: 0, + Mode: "http", + } + + _, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, req) + require.Error(t, err) + assert.Contains(t, err.Error(), "port") + }) +} + +func TestExposeServiceRequestValidate(t *testing.T) { + tests := []struct { + name string + req rpservice.ExposeServiceRequest + wantErr string + }{ + { + name: "valid http request", + req: rpservice.ExposeServiceRequest{Port: 8080, Mode: "http"}, + wantErr: "", + }, + { + name: "https mode rejected", + req: rpservice.ExposeServiceRequest{Port: 443, Mode: "https", Pin: "123456"}, + wantErr: "unsupported mode", + }, + { + name: "port zero rejected", + req: rpservice.ExposeServiceRequest{Port: 0, Mode: "http"}, + wantErr: "port must be between 1 and 65535", + }, + { + name: "unsupported mode", + req: rpservice.ExposeServiceRequest{Port: 80, Mode: "ftp"}, + wantErr: "unsupported mode", + }, + { + name: "invalid pin format", + req: rpservice.ExposeServiceRequest{Port: 80, Mode: "http", Pin: "abc"}, + wantErr: "invalid pin", + }, + { + name: "pin too short", + req: rpservice.ExposeServiceRequest{Port: 80, Mode: "http", Pin: "12345"}, + wantErr: "invalid pin", + }, + { + name: "valid 6-digit pin", + req: rpservice.ExposeServiceRequest{Port: 80, Mode: "http", Pin: "000000"}, + wantErr: "", + }, + { + name: "empty user group name", + req: rpservice.ExposeServiceRequest{Port: 80, Mode: "http", UserGroups: []string{"valid", ""}}, + wantErr: "user group name cannot be empty", + }, + { + name: "invalid name prefix", + req: rpservice.ExposeServiceRequest{Port: 80, Mode: "http", NamePrefix: "INVALID"}, + wantErr: "invalid name prefix", + }, + { + name: "valid name prefix", + req: rpservice.ExposeServiceRequest{Port: 80, Mode: "http", NamePrefix: "my-service"}, + wantErr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.req.Validate() + if tt.wantErr == "" { + assert.NoError(t, err) + } else { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } + }) + } + + t.Run("nil receiver", func(t *testing.T) { + var req *rpservice.ExposeServiceRequest + err := req.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "request cannot be nil") + }) +} + +func TestDeleteServiceFromPeer_ByDomain(t *testing.T) { + ctx := context.Background() + + t.Run("deletes service by domain", func(t *testing.T) { + mgr, testStore := setupIntegrationTest(t) + + // First create a service + req := &rpservice.ExposeServiceRequest{ + Port: 8080, + Mode: "http", + } + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, req) + require.NoError(t, err) + + svcID := resolveServiceIDByDomain(t, testStore, resp.Domain) + err = mgr.deleteServiceFromPeer(ctx, testAccountID, testPeerID, svcID, false) + require.NoError(t, err) + + // Verify service is deleted + _, err = testStore.GetServiceByDomain(ctx, resp.Domain) + require.Error(t, err, "service should be deleted") + }) + + t.Run("expire uses correct activity", func(t *testing.T) { + mgr, testStore := setupIntegrationTest(t) + + req := &rpservice.ExposeServiceRequest{ + Port: 8080, + Mode: "http", + } + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, req) + require.NoError(t, err) + + svcID := resolveServiceIDByDomain(t, testStore, resp.Domain) + err = mgr.deleteServiceFromPeer(ctx, testAccountID, testPeerID, svcID, true) + require.NoError(t, err) + }) +} + +func TestStopServiceFromPeer(t *testing.T) { + ctx := context.Background() + + t.Run("stops service by domain", func(t *testing.T) { + mgr, testStore := setupIntegrationTest(t) + + req := &rpservice.ExposeServiceRequest{ + Port: 8080, + Mode: "http", + } + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, req) + require.NoError(t, err) + + svcID := resolveServiceIDByDomain(t, testStore, resp.Domain) + err = mgr.StopServiceFromPeer(ctx, testAccountID, testPeerID, svcID) + require.NoError(t, err) + + _, err = testStore.GetServiceByDomain(ctx, resp.Domain) + require.Error(t, err, "service should be deleted") + }) +} + +func TestDeleteService_DeletesEphemeralExpose(t *testing.T) { + ctx := context.Background() + mgr, testStore := setupIntegrationTest(t) + + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 8080, + Mode: "http", + }) + require.NoError(t, err) + + count, err := mgr.store.CountEphemeralServicesByPeer(ctx, store.LockingStrengthNone, testAccountID, testPeerID) + require.NoError(t, err) + assert.Equal(t, int64(1), count, "one ephemeral service should exist after create") + + svc, err := testStore.GetServiceByDomain(ctx, resp.Domain) + require.NoError(t, err) + + err = mgr.DeleteService(ctx, testAccountID, testUserID, svc.ID) + require.NoError(t, err) + + count, err = mgr.store.CountEphemeralServicesByPeer(ctx, store.LockingStrengthNone, testAccountID, testPeerID) + require.NoError(t, err) + assert.Equal(t, int64(0), count, "ephemeral service should be deleted after API delete") + + _, err = mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 9090, + Mode: "http", + }) + assert.NoError(t, err, "new expose should succeed after API delete") +} + +func TestDeleteAllServices_DeletesEphemeralExposes(t *testing.T) { + ctx := context.Background() + mgr, _ := setupIntegrationTest(t) + + for i := range 3 { + _, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: uint16(8080 + i), + Mode: "http", + }) + require.NoError(t, err) + } + + count, err := mgr.store.CountEphemeralServicesByPeer(ctx, store.LockingStrengthNone, testAccountID, testPeerID) + require.NoError(t, err) + assert.Equal(t, int64(3), count, "all ephemeral services should exist") + + err = mgr.DeleteAllServices(ctx, testAccountID, testUserID) + require.NoError(t, err) + + count, err = mgr.store.CountEphemeralServicesByPeer(ctx, store.LockingStrengthNone, testAccountID, testPeerID) + require.NoError(t, err) + assert.Equal(t, int64(0), count, "all ephemeral services should be deleted after DeleteAllServices") +} + +func TestRenewServiceFromPeer(t *testing.T) { + ctx := context.Background() + + t.Run("renews tracked expose", func(t *testing.T) { + mgr, testStore := setupIntegrationTest(t) + + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 8080, + Mode: "http", + }) + require.NoError(t, err) + + svcID := resolveServiceIDByDomain(t, testStore, resp.Domain) + err = mgr.RenewServiceFromPeer(ctx, testAccountID, testPeerID, svcID) + require.NoError(t, err) + }) + + t.Run("fails for untracked domain", func(t *testing.T) { + mgr, _ := setupIntegrationTest(t) + err := mgr.RenewServiceFromPeer(ctx, testAccountID, testPeerID, "nonexistent-service-id") + require.Error(t, err) + }) +} + +func TestGetGroupIDsFromNames(t *testing.T) { + ctx := context.Background() + + t.Run("resolves group names to IDs", func(t *testing.T) { + mgr, _ := setupIntegrationTest(t) + ids, err := mgr.getGroupIDsFromNames(ctx, testAccountID, []string{"Expose Group"}) + require.NoError(t, err) + require.Len(t, ids, 1, "should return exactly one group ID") + assert.Equal(t, testGroupID, ids[0]) + }) + + t.Run("returns error for unknown group", func(t *testing.T) { + mgr, _ := setupIntegrationTest(t) + _, err := mgr.getGroupIDsFromNames(ctx, testAccountID, []string{"nonexistent"}) + require.Error(t, err) + }) + + t.Run("returns error for empty group list", func(t *testing.T) { + mgr, _ := setupIntegrationTest(t) + _, err := mgr.getGroupIDsFromNames(ctx, testAccountID, []string{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no group names provided") + }) +} + +func TestDeleteService_DeletesTargets(t *testing.T) { + ctx := context.Background() + accountID := "test-account" + userID := "test-user" + + sqlStore, err := store.NewStore(ctx, types.SqliteStoreEngine, t.TempDir(), nil, false) + require.NoError(t, err) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockPerms := permissions.NewMockManager(ctrl) + mockAcct := account.NewMockManager(ctrl) + + tokenStore, err := nbgrpc.NewOneTimeTokenStore(ctx, 1*time.Hour, 10*time.Minute, 100) + require.NoError(t, err) + pkceStore, err := nbgrpc.NewPKCEVerifierStore(ctx, 10*time.Minute, 10*time.Minute, 100) + require.NoError(t, err) + proxySrv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil) + + proxyController, err := proxymanager.NewGRPCController(proxySrv, noop.NewMeterProvider().Meter("")) + require.NoError(t, err) + + mgr := &Manager{ + store: sqlStore, + permissionsManager: mockPerms, + accountManager: mockAcct, + proxyController: proxyController, + } + + service := &rpservice.Service{ + ID: "service-1", + AccountID: accountID, + Domain: "test.example.com", + ProxyCluster: "cluster1", + Enabled: true, + Targets: []*rpservice.Target{ + {AccountID: accountID, ServiceID: "service-1", TargetType: rpservice.TargetTypePeer, TargetId: "peer-1"}, + {AccountID: accountID, ServiceID: "service-1", TargetType: rpservice.TargetTypePeer, TargetId: "peer-2"}, + {AccountID: accountID, ServiceID: "service-1", TargetType: rpservice.TargetTypePeer, TargetId: "peer-3"}, + }, + } + + err = sqlStore.CreateService(ctx, service) + require.NoError(t, err) + + retrievedService, err := sqlStore.GetServiceByID(ctx, store.LockingStrengthNone, accountID, service.ID) + require.NoError(t, err) + require.Len(t, retrievedService.Targets, 3, "Service should have 3 targets before deletion") + + mockPerms.EXPECT(). + ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete). + Return(true, nil) + mockAcct.EXPECT(). + StoreEvent(ctx, userID, service.ID, accountID, activity.ServiceDeleted, gomock.Any()) + mockAcct.EXPECT(). + UpdateAccountPeers(ctx, accountID) + + err = mgr.DeleteService(ctx, accountID, userID, service.ID) + require.NoError(t, err) + + _, err = sqlStore.GetServiceByID(ctx, store.LockingStrengthNone, accountID, service.ID) + require.Error(t, err) + s, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.NotFound, s.Type()) + + targets, err := sqlStore.GetTargetsByServiceID(ctx, store.LockingStrengthNone, accountID, service.ID) + require.NoError(t, err) + assert.Len(t, targets, 0, "All targets should be deleted when service is deleted") +} + +func TestValidateProtocolChange(t *testing.T) { + tests := []struct { + name string + oldP string + newP string + wantErr bool + }{ + {"empty to http", "", "http", false}, + {"http to http", "http", "http", false}, + {"same protocol", "tcp", "tcp", false}, + {"empty new proto", "tcp", "", false}, + {"http to tcp", "http", "tcp", true}, + {"tcp to udp", "tcp", "udp", true}, + {"tls to http", "tls", "http", true}, + {"udp to tls", "udp", "tls", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateProtocolChange(tt.oldP, tt.newP) + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot change mode") + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/management/internals/modules/reverseproxy/service/service.go b/management/internals/modules/reverseproxy/service/service.go new file mode 100644 index 000000000..6c7c80806 --- /dev/null +++ b/management/internals/modules/reverseproxy/service/service.go @@ -0,0 +1,1349 @@ +package service + +import ( + "crypto/rand" + "errors" + "fmt" + "math/big" + "net" + "net/http" + "net/netip" + "net/url" + "regexp" + "slices" + "strconv" + "strings" + "time" + + "github.com/rs/xid" + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + "github.com/netbirdio/netbird/shared/hash/argon2id" + "github.com/netbirdio/netbird/util/crypt" + + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/proto" +) + +type Operation string + +const ( + Create Operation = "create" + Update Operation = "update" + Delete Operation = "delete" +) + +type Status string +type TargetType string + +const ( + StatusPending Status = "pending" + StatusActive Status = "active" + StatusTunnelNotCreated Status = "tunnel_not_created" + StatusCertificatePending Status = "certificate_pending" + StatusCertificateFailed Status = "certificate_failed" + StatusError Status = "error" + + TargetTypePeer TargetType = "peer" + TargetTypeHost TargetType = "host" + TargetTypeDomain TargetType = "domain" + TargetTypeSubnet TargetType = "subnet" + + SourcePermanent = "permanent" + SourceEphemeral = "ephemeral" +) + +type TargetOptions struct { + SkipTLSVerify bool `json:"skip_tls_verify"` + RequestTimeout time.Duration `json:"request_timeout,omitempty"` + SessionIdleTimeout time.Duration `json:"session_idle_timeout,omitempty"` + PathRewrite PathRewriteMode `json:"path_rewrite,omitempty"` + CustomHeaders map[string]string `gorm:"serializer:json" json:"custom_headers,omitempty"` +} + +type Target struct { + ID uint `gorm:"primaryKey" json:"-"` + AccountID string `gorm:"index:idx_target_account;not null" json:"-"` + ServiceID string `gorm:"index:idx_service_targets;not null" json:"-"` + Path *string `json:"path,omitempty"` + Host string `json:"host"` // the Host field is only used for subnet targets, otherwise ignored + Port uint16 `gorm:"index:idx_target_port" json:"port"` + Protocol string `gorm:"index:idx_target_protocol" json:"protocol"` + TargetId string `gorm:"index:idx_target_id" json:"target_id"` + TargetType TargetType `gorm:"index:idx_target_type" json:"target_type"` + Enabled bool `gorm:"index:idx_target_enabled" json:"enabled"` + Options TargetOptions `gorm:"embedded" json:"options"` + ProxyProtocol bool `json:"proxy_protocol"` +} + +type PasswordAuthConfig struct { + Enabled bool `json:"enabled"` + Password string `json:"password"` +} + +type PINAuthConfig struct { + Enabled bool `json:"enabled"` + Pin string `json:"pin"` +} + +type BearerAuthConfig struct { + Enabled bool `json:"enabled"` + DistributionGroups []string `json:"distribution_groups,omitempty" gorm:"serializer:json"` +} + +// HeaderAuthConfig defines a static header-value auth check. +// The proxy compares the incoming header value against the stored hash. +type HeaderAuthConfig struct { + Enabled bool `json:"enabled"` + Header string `json:"header"` + Value string `json:"value"` +} + +type AuthConfig struct { + PasswordAuth *PasswordAuthConfig `json:"password_auth,omitempty" gorm:"serializer:json"` + PinAuth *PINAuthConfig `json:"pin_auth,omitempty" gorm:"serializer:json"` + BearerAuth *BearerAuthConfig `json:"bearer_auth,omitempty" gorm:"serializer:json"` + HeaderAuths []*HeaderAuthConfig `json:"header_auths,omitempty" gorm:"serializer:json"` +} + +// AccessRestrictions controls who can connect to the service based on IP or geography. +type AccessRestrictions struct { + AllowedCIDRs []string `json:"allowed_cidrs,omitempty" gorm:"serializer:json"` + BlockedCIDRs []string `json:"blocked_cidrs,omitempty" gorm:"serializer:json"` + AllowedCountries []string `json:"allowed_countries,omitempty" gorm:"serializer:json"` + BlockedCountries []string `json:"blocked_countries,omitempty" gorm:"serializer:json"` +} + +// Copy returns a deep copy of the AccessRestrictions. +func (r AccessRestrictions) Copy() AccessRestrictions { + return AccessRestrictions{ + AllowedCIDRs: slices.Clone(r.AllowedCIDRs), + BlockedCIDRs: slices.Clone(r.BlockedCIDRs), + AllowedCountries: slices.Clone(r.AllowedCountries), + BlockedCountries: slices.Clone(r.BlockedCountries), + } +} + +func (a *AuthConfig) HashSecrets() error { + if a.PasswordAuth != nil && a.PasswordAuth.Enabled && a.PasswordAuth.Password != "" { + hashedPassword, err := argon2id.Hash(a.PasswordAuth.Password) + if err != nil { + return fmt.Errorf("hash password: %w", err) + } + a.PasswordAuth.Password = hashedPassword + } + + if a.PinAuth != nil && a.PinAuth.Enabled && a.PinAuth.Pin != "" { + hashedPin, err := argon2id.Hash(a.PinAuth.Pin) + if err != nil { + return fmt.Errorf("hash pin: %w", err) + } + a.PinAuth.Pin = hashedPin + } + + for i, h := range a.HeaderAuths { + if h != nil && h.Enabled && h.Value != "" { + hashedValue, err := argon2id.Hash(h.Value) + if err != nil { + return fmt.Errorf("hash header auth[%d] value: %w", i, err) + } + h.Value = hashedValue + } + } + + return nil +} + +func (a *AuthConfig) ClearSecrets() { + if a.PasswordAuth != nil { + a.PasswordAuth.Password = "" + } + if a.PinAuth != nil { + a.PinAuth.Pin = "" + } + for _, h := range a.HeaderAuths { + if h != nil { + h.Value = "" + } + } +} + +type Meta struct { + CreatedAt time.Time + CertificateIssuedAt *time.Time + Status string + LastRenewedAt *time.Time +} + +type Service struct { + ID string `gorm:"primaryKey"` + AccountID string `gorm:"index"` + Name string + Domain string `gorm:"type:varchar(255);uniqueIndex"` + ProxyCluster string `gorm:"index"` + Targets []*Target `gorm:"foreignKey:ServiceID;constraint:OnDelete:CASCADE"` + Enabled bool + PassHostHeader bool + RewriteRedirects bool + Auth AuthConfig `gorm:"serializer:json"` + Restrictions AccessRestrictions `gorm:"serializer:json"` + Meta Meta `gorm:"embedded;embeddedPrefix:meta_"` + SessionPrivateKey string `gorm:"column:session_private_key"` + SessionPublicKey string `gorm:"column:session_public_key"` + Source string `gorm:"default:'permanent';index:idx_service_source_peer"` + SourcePeer string `gorm:"index:idx_service_source_peer"` + // Mode determines the service type: "http", "tcp", "udp", or "tls". + Mode string `gorm:"default:'http'"` + ListenPort uint16 + PortAutoAssigned bool +} + +// InitNewRecord generates a new unique ID and resets metadata for a newly created +// Service record. This overwrites any existing ID and Meta fields and should +// only be called during initial creation, not for updates. +func (s *Service) InitNewRecord() { + s.ID = xid.New().String() + s.Meta = Meta{ + CreatedAt: time.Now(), + Status: string(StatusPending), + } +} + +func (s *Service) ToAPIResponse() *api.Service { + authConfig := api.ServiceAuthConfig{} + + if s.Auth.PasswordAuth != nil { + authConfig.PasswordAuth = &api.PasswordAuthConfig{ + Enabled: s.Auth.PasswordAuth.Enabled, + } + } + + if s.Auth.PinAuth != nil { + authConfig.PinAuth = &api.PINAuthConfig{ + Enabled: s.Auth.PinAuth.Enabled, + } + } + + if s.Auth.BearerAuth != nil { + authConfig.BearerAuth = &api.BearerAuthConfig{ + Enabled: s.Auth.BearerAuth.Enabled, + DistributionGroups: &s.Auth.BearerAuth.DistributionGroups, + } + } + + if len(s.Auth.HeaderAuths) > 0 { + apiHeaders := make([]api.HeaderAuthConfig, 0, len(s.Auth.HeaderAuths)) + for _, h := range s.Auth.HeaderAuths { + if h == nil { + continue + } + apiHeaders = append(apiHeaders, api.HeaderAuthConfig{ + Enabled: h.Enabled, + Header: h.Header, + }) + } + authConfig.HeaderAuths = &apiHeaders + } + + // Convert internal targets to API targets + apiTargets := make([]api.ServiceTarget, 0, len(s.Targets)) + for _, target := range s.Targets { + st := api.ServiceTarget{ + Path: target.Path, + Host: &target.Host, + Port: int(target.Port), + Protocol: api.ServiceTargetProtocol(target.Protocol), + TargetId: target.TargetId, + TargetType: api.ServiceTargetTargetType(target.TargetType), + Enabled: target.Enabled, + } + opts := targetOptionsToAPI(target.Options) + if opts == nil { + opts = &api.ServiceTargetOptions{} + } + opts.ProxyProtocol = &target.ProxyProtocol + st.Options = opts + apiTargets = append(apiTargets, st) + } + + meta := api.ServiceMeta{ + CreatedAt: s.Meta.CreatedAt, + Status: api.ServiceMetaStatus(s.Meta.Status), + } + + if s.Meta.CertificateIssuedAt != nil { + meta.CertificateIssuedAt = s.Meta.CertificateIssuedAt + } + + mode := api.ServiceMode(s.Mode) + listenPort := int(s.ListenPort) + + resp := &api.Service{ + Id: s.ID, + Name: s.Name, + Domain: s.Domain, + Targets: apiTargets, + Enabled: s.Enabled, + PassHostHeader: &s.PassHostHeader, + RewriteRedirects: &s.RewriteRedirects, + Auth: authConfig, + AccessRestrictions: restrictionsToAPI(s.Restrictions), + Meta: meta, + Mode: &mode, + ListenPort: &listenPort, + PortAutoAssigned: &s.PortAutoAssigned, + } + + if s.ProxyCluster != "" { + resp.ProxyCluster = &s.ProxyCluster + } + + return resp +} + +func (s *Service) ToProtoMapping(operation Operation, authToken string, oidcConfig proxy.OIDCValidationConfig) *proto.ProxyMapping { + pathMappings := s.buildPathMappings() + + auth := &proto.Authentication{ + SessionKey: s.SessionPublicKey, + MaxSessionAgeSeconds: int64((time.Hour * 24).Seconds()), + } + + if s.Auth.PasswordAuth != nil && s.Auth.PasswordAuth.Enabled { + auth.Password = true + } + + if s.Auth.PinAuth != nil && s.Auth.PinAuth.Enabled { + auth.Pin = true + } + + if s.Auth.BearerAuth != nil && s.Auth.BearerAuth.Enabled { + auth.Oidc = true + } + + for _, h := range s.Auth.HeaderAuths { + if h != nil && h.Enabled { + auth.HeaderAuths = append(auth.HeaderAuths, &proto.HeaderAuth{ + Header: h.Header, + HashedValue: h.Value, + }) + } + } + + mapping := &proto.ProxyMapping{ + Type: operationToProtoType(operation), + Id: s.ID, + Domain: s.Domain, + Path: pathMappings, + AuthToken: authToken, + Auth: auth, + AccountId: s.AccountID, + PassHostHeader: s.PassHostHeader, + RewriteRedirects: s.RewriteRedirects, + Mode: s.Mode, + ListenPort: int32(s.ListenPort), //nolint:gosec + } + + if r := restrictionsToProto(s.Restrictions); r != nil { + mapping.AccessRestrictions = r + } + + return mapping +} + +// buildPathMappings constructs PathMapping entries from targets. +// For HTTP/HTTPS, each target becomes a path-based route with a full URL. +// For L4/TLS, a single target maps to a host:port address. +func (s *Service) buildPathMappings() []*proto.PathMapping { + pathMappings := make([]*proto.PathMapping, 0, len(s.Targets)) + for _, target := range s.Targets { + if !target.Enabled { + continue + } + + if IsL4Protocol(s.Mode) { + pm := &proto.PathMapping{ + Target: net.JoinHostPort(target.Host, strconv.FormatUint(uint64(target.Port), 10)), + } + opts := l4TargetOptionsToProto(target) + if opts != nil { + pm.Options = opts + } + pathMappings = append(pathMappings, pm) + continue + } + + // HTTP/HTTPS: build full URL + targetURL := url.URL{ + Scheme: target.Protocol, + Host: target.Host, + Path: "/", + } + if target.Port > 0 && !isDefaultPort(target.Protocol, target.Port) { + targetURL.Host = net.JoinHostPort(targetURL.Host, strconv.FormatUint(uint64(target.Port), 10)) + } + + path := "/" + if target.Path != nil { + path = *target.Path + } + + pm := &proto.PathMapping{ + Path: path, + Target: targetURL.String(), + } + pm.Options = targetOptionsToProto(target.Options) + pathMappings = append(pathMappings, pm) + } + return pathMappings +} + +func operationToProtoType(op Operation) proto.ProxyMappingUpdateType { + switch op { + case Create: + return proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED + case Update: + return proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED + case Delete: + return proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED + default: + panic(fmt.Sprintf("unknown operation type: %v", op)) + } +} + +// isDefaultPort reports whether port is the standard default for the given scheme +// (443 for https, 80 for http). +func isDefaultPort(scheme string, port uint16) bool { + return (scheme == TargetProtoHTTPS && port == 443) || (scheme == TargetProtoHTTP && port == 80) +} + +// PathRewriteMode controls how the request path is rewritten before forwarding. +type PathRewriteMode string + +const ( + PathRewritePreserve PathRewriteMode = "preserve" +) + +func pathRewriteToProto(mode PathRewriteMode) proto.PathRewriteMode { + switch mode { + case PathRewritePreserve: + return proto.PathRewriteMode_PATH_REWRITE_PRESERVE + default: + return proto.PathRewriteMode_PATH_REWRITE_DEFAULT + } +} + +func targetOptionsToAPI(opts TargetOptions) *api.ServiceTargetOptions { + if !opts.SkipTLSVerify && opts.RequestTimeout == 0 && opts.SessionIdleTimeout == 0 && opts.PathRewrite == "" && len(opts.CustomHeaders) == 0 { + return nil + } + apiOpts := &api.ServiceTargetOptions{} + if opts.SkipTLSVerify { + apiOpts.SkipTlsVerify = &opts.SkipTLSVerify + } + if opts.RequestTimeout != 0 { + s := opts.RequestTimeout.String() + apiOpts.RequestTimeout = &s + } + if opts.SessionIdleTimeout != 0 { + s := opts.SessionIdleTimeout.String() + apiOpts.SessionIdleTimeout = &s + } + if opts.PathRewrite != "" { + pr := api.ServiceTargetOptionsPathRewrite(opts.PathRewrite) + apiOpts.PathRewrite = &pr + } + if len(opts.CustomHeaders) > 0 { + apiOpts.CustomHeaders = &opts.CustomHeaders + } + return apiOpts +} + +func targetOptionsToProto(opts TargetOptions) *proto.PathTargetOptions { + if !opts.SkipTLSVerify && opts.PathRewrite == "" && opts.RequestTimeout == 0 && len(opts.CustomHeaders) == 0 { + return nil + } + popts := &proto.PathTargetOptions{ + SkipTlsVerify: opts.SkipTLSVerify, + PathRewrite: pathRewriteToProto(opts.PathRewrite), + CustomHeaders: opts.CustomHeaders, + } + if opts.RequestTimeout != 0 { + popts.RequestTimeout = durationpb.New(opts.RequestTimeout) + } + return popts +} + +// l4TargetOptionsToProto converts L4-relevant target options to proto. +func l4TargetOptionsToProto(target *Target) *proto.PathTargetOptions { + if !target.ProxyProtocol && target.Options.RequestTimeout == 0 && target.Options.SessionIdleTimeout == 0 { + return nil + } + opts := &proto.PathTargetOptions{ + ProxyProtocol: target.ProxyProtocol, + } + if target.Options.RequestTimeout > 0 { + opts.RequestTimeout = durationpb.New(target.Options.RequestTimeout) + } + if target.Options.SessionIdleTimeout > 0 { + opts.SessionIdleTimeout = durationpb.New(target.Options.SessionIdleTimeout) + } + return opts +} + +func targetOptionsFromAPI(idx int, o *api.ServiceTargetOptions) (TargetOptions, error) { + var opts TargetOptions + if o.SkipTlsVerify != nil { + opts.SkipTLSVerify = *o.SkipTlsVerify + } + if o.RequestTimeout != nil { + d, err := time.ParseDuration(*o.RequestTimeout) + if err != nil { + return opts, fmt.Errorf("target %d: parse request_timeout %q: %w", idx, *o.RequestTimeout, err) + } + opts.RequestTimeout = d + } + if o.SessionIdleTimeout != nil { + d, err := time.ParseDuration(*o.SessionIdleTimeout) + if err != nil { + return opts, fmt.Errorf("target %d: parse session_idle_timeout %q: %w", idx, *o.SessionIdleTimeout, err) + } + opts.SessionIdleTimeout = d + } + if o.PathRewrite != nil { + opts.PathRewrite = PathRewriteMode(*o.PathRewrite) + } + if o.CustomHeaders != nil { + opts.CustomHeaders = *o.CustomHeaders + } + return opts, nil +} + +func (s *Service) FromAPIRequest(req *api.ServiceRequest, accountID string) error { + s.Name = req.Name + s.Domain = req.Domain + s.AccountID = accountID + + if req.Mode != nil { + s.Mode = string(*req.Mode) + } + if req.ListenPort != nil { + s.ListenPort = uint16(*req.ListenPort) //nolint:gosec + } + + targets, err := targetsFromAPI(accountID, req.Targets) + if err != nil { + return err + } + s.Targets = targets + s.Enabled = req.Enabled + + if req.PassHostHeader != nil { + s.PassHostHeader = *req.PassHostHeader + } + if req.RewriteRedirects != nil { + s.RewriteRedirects = *req.RewriteRedirects + } + + if req.Auth != nil { + s.Auth = authFromAPI(req.Auth) + } + + if req.AccessRestrictions != nil { + s.Restrictions = restrictionsFromAPI(req.AccessRestrictions) + } + + return nil +} + +func targetsFromAPI(accountID string, apiTargetsPtr *[]api.ServiceTarget) ([]*Target, error) { + var apiTargets []api.ServiceTarget + if apiTargetsPtr != nil { + apiTargets = *apiTargetsPtr + } + + targets := make([]*Target, 0, len(apiTargets)) + for i, apiTarget := range apiTargets { + target := &Target{ + AccountID: accountID, + Path: apiTarget.Path, + Port: uint16(apiTarget.Port), //nolint:gosec // validated by API layer + Protocol: string(apiTarget.Protocol), + TargetId: apiTarget.TargetId, + TargetType: TargetType(apiTarget.TargetType), + Enabled: apiTarget.Enabled, + } + if apiTarget.Host != nil { + target.Host = *apiTarget.Host + } + if apiTarget.Options != nil { + opts, err := targetOptionsFromAPI(i, apiTarget.Options) + if err != nil { + return nil, err + } + target.Options = opts + if apiTarget.Options.ProxyProtocol != nil { + target.ProxyProtocol = *apiTarget.Options.ProxyProtocol + } + } + targets = append(targets, target) + } + return targets, nil +} + +func authFromAPI(reqAuth *api.ServiceAuthConfig) AuthConfig { + var auth AuthConfig + if reqAuth.PasswordAuth != nil { + auth.PasswordAuth = &PasswordAuthConfig{ + Enabled: reqAuth.PasswordAuth.Enabled, + Password: reqAuth.PasswordAuth.Password, + } + } + if reqAuth.PinAuth != nil { + auth.PinAuth = &PINAuthConfig{ + Enabled: reqAuth.PinAuth.Enabled, + Pin: reqAuth.PinAuth.Pin, + } + } + if reqAuth.BearerAuth != nil { + bearerAuth := &BearerAuthConfig{ + Enabled: reqAuth.BearerAuth.Enabled, + } + if reqAuth.BearerAuth.DistributionGroups != nil { + bearerAuth.DistributionGroups = *reqAuth.BearerAuth.DistributionGroups + } + auth.BearerAuth = bearerAuth + } + if reqAuth.HeaderAuths != nil { + for _, h := range *reqAuth.HeaderAuths { + auth.HeaderAuths = append(auth.HeaderAuths, &HeaderAuthConfig{ + Enabled: h.Enabled, + Header: h.Header, + Value: h.Value, + }) + } + } + return auth +} + +func restrictionsFromAPI(r *api.AccessRestrictions) AccessRestrictions { + if r == nil { + return AccessRestrictions{} + } + var res AccessRestrictions + if r.AllowedCidrs != nil { + res.AllowedCIDRs = *r.AllowedCidrs + } + if r.BlockedCidrs != nil { + res.BlockedCIDRs = *r.BlockedCidrs + } + if r.AllowedCountries != nil { + res.AllowedCountries = *r.AllowedCountries + } + if r.BlockedCountries != nil { + res.BlockedCountries = *r.BlockedCountries + } + return res +} + +func restrictionsToAPI(r AccessRestrictions) *api.AccessRestrictions { + if len(r.AllowedCIDRs) == 0 && len(r.BlockedCIDRs) == 0 && len(r.AllowedCountries) == 0 && len(r.BlockedCountries) == 0 { + return nil + } + res := &api.AccessRestrictions{} + if len(r.AllowedCIDRs) > 0 { + res.AllowedCidrs = &r.AllowedCIDRs + } + if len(r.BlockedCIDRs) > 0 { + res.BlockedCidrs = &r.BlockedCIDRs + } + if len(r.AllowedCountries) > 0 { + res.AllowedCountries = &r.AllowedCountries + } + if len(r.BlockedCountries) > 0 { + res.BlockedCountries = &r.BlockedCountries + } + return res +} + +func restrictionsToProto(r AccessRestrictions) *proto.AccessRestrictions { + if len(r.AllowedCIDRs) == 0 && len(r.BlockedCIDRs) == 0 && len(r.AllowedCountries) == 0 && len(r.BlockedCountries) == 0 { + return nil + } + return &proto.AccessRestrictions{ + AllowedCidrs: r.AllowedCIDRs, + BlockedCidrs: r.BlockedCIDRs, + AllowedCountries: r.AllowedCountries, + BlockedCountries: r.BlockedCountries, + } +} + +func (s *Service) Validate() error { + if s.Name == "" { + return errors.New("service name is required") + } + if len(s.Name) > 255 { + return errors.New("service name exceeds maximum length of 255 characters") + } + + if len(s.Targets) == 0 { + return errors.New("at least one target is required") + } + + if s.Mode == "" { + s.Mode = ModeHTTP + } + + if err := validateHeaderAuths(s.Auth.HeaderAuths); err != nil { + return err + } + if err := validateAccessRestrictions(&s.Restrictions); err != nil { + return err + } + + switch s.Mode { + case ModeHTTP: + return s.validateHTTPMode() + case ModeTCP, ModeUDP: + return s.validateTCPUDPMode() + case ModeTLS: + return s.validateTLSMode() + default: + return fmt.Errorf("unsupported mode %q", s.Mode) + } +} + +func (s *Service) validateHTTPMode() error { + if s.Domain == "" { + return errors.New("service domain is required") + } + if s.ListenPort != 0 { + return errors.New("listen_port is not supported for HTTP services") + } + return s.validateHTTPTargets() +} + +func (s *Service) validateTCPUDPMode() error { + if s.Domain == "" { + return errors.New("domain is required for TCP/UDP services (used for cluster derivation)") + } + if s.isAuthEnabled() { + return errors.New("auth is not supported for TCP/UDP services") + } + if len(s.Targets) != 1 { + return errors.New("TCP/UDP services must have exactly one target") + } + if s.Mode == ModeUDP && s.Targets[0].ProxyProtocol { + return errors.New("proxy_protocol is not supported for UDP services") + } + return s.validateL4Target(s.Targets[0]) +} + +func (s *Service) validateTLSMode() error { + if s.Domain == "" { + return errors.New("domain is required for TLS services (used for SNI matching)") + } + if s.isAuthEnabled() { + return errors.New("auth is not supported for TLS services") + } + if s.ListenPort == 0 { + return errors.New("listen_port is required for TLS services") + } + if len(s.Targets) != 1 { + return errors.New("TLS services must have exactly one target") + } + return s.validateL4Target(s.Targets[0]) +} + +func (s *Service) validateHTTPTargets() error { + for i, target := range s.Targets { + switch target.TargetType { + case TargetTypePeer, TargetTypeHost, TargetTypeDomain: + // host field will be ignored + case TargetTypeSubnet: + if target.Host == "" { + return fmt.Errorf("target %d has empty host but target_type is %q", i, target.TargetType) + } + default: + return fmt.Errorf("target %d has invalid target_type %q", i, target.TargetType) + } + if target.TargetId == "" { + return fmt.Errorf("target %d has empty target_id", i) + } + if target.ProxyProtocol { + return fmt.Errorf("target %d: proxy_protocol is not supported for HTTP services", i) + } + if err := validateTargetOptions(i, &target.Options); err != nil { + return err + } + } + + return nil +} + +func (s *Service) validateL4Target(target *Target) error { + if target.Port == 0 { + return errors.New("target port is required for L4 services") + } + if target.TargetId == "" { + return errors.New("target_id is required for L4 services") + } + switch target.TargetType { + case TargetTypePeer, TargetTypeHost: + // OK + case TargetTypeSubnet: + if target.Host == "" { + return errors.New("target host is required for subnet targets") + } + default: + return fmt.Errorf("invalid target_type %q for L4 service", target.TargetType) + } + if target.Path != nil && *target.Path != "" && *target.Path != "/" { + return errors.New("path is not supported for L4 services") + } + if target.Options.SessionIdleTimeout < 0 { + return errors.New("session_idle_timeout must be positive for L4 services") + } + if target.Options.RequestTimeout < 0 { + return errors.New("request_timeout must be positive for L4 services") + } + if target.Options.SkipTLSVerify { + return errors.New("skip_tls_verify is not supported for L4 services") + } + if target.Options.PathRewrite != "" { + return errors.New("path_rewrite is not supported for L4 services") + } + if len(target.Options.CustomHeaders) > 0 { + return errors.New("custom_headers is not supported for L4 services") + } + return nil +} + +// Service mode constants. +const ( + ModeHTTP = "http" + ModeTCP = "tcp" + ModeUDP = "udp" + ModeTLS = "tls" +) + +// Target protocol constants (URL scheme for backend connections). +const ( + TargetProtoHTTP = "http" + TargetProtoHTTPS = "https" + TargetProtoTCP = "tcp" + TargetProtoUDP = "udp" +) + +// IsL4Protocol returns true if the mode requires port-based routing (TCP, UDP, or TLS). +func IsL4Protocol(mode string) bool { + return mode == ModeTCP || mode == ModeUDP || mode == ModeTLS +} + +// IsPortBasedProtocol returns true if the mode relies on dedicated port allocation. +// TLS is excluded because it uses SNI routing and can share ports with other TLS services. +func IsPortBasedProtocol(mode string) bool { + return mode == ModeTCP || mode == ModeUDP +} + +const ( + maxCustomHeaders = 16 + maxHeaderKeyLen = 128 + maxHeaderValueLen = 4096 +) + +// httpHeaderNameRe matches valid HTTP header field names per RFC 7230 token definition. +var httpHeaderNameRe = regexp.MustCompile(`^[!#$%&'*+\-.^_` + "`" + `|~0-9A-Za-z]+$`) + +// hopByHopHeaders are headers that must not be set as custom headers +// because they are connection-level and stripped by the proxy. +var hopByHopHeaders = map[string]struct{}{ + "Connection": {}, + "Keep-Alive": {}, + "Proxy-Authenticate": {}, + "Proxy-Authorization": {}, + "Proxy-Connection": {}, + "Te": {}, + "Trailer": {}, + "Transfer-Encoding": {}, + "Upgrade": {}, +} + +// reservedHeaders are set authoritatively by the proxy or control HTTP framing +// and cannot be overridden. +var reservedHeaders = map[string]struct{}{ + "Content-Length": {}, + "Content-Type": {}, + "Cookie": {}, + "Forwarded": {}, + "X-Forwarded-For": {}, + "X-Forwarded-Host": {}, + "X-Forwarded-Port": {}, + "X-Forwarded-Proto": {}, + "X-Real-Ip": {}, +} + +func validateTargetOptions(idx int, opts *TargetOptions) error { + if opts.PathRewrite != "" && opts.PathRewrite != PathRewritePreserve { + return fmt.Errorf("target %d: unknown path_rewrite mode %q", idx, opts.PathRewrite) + } + + if opts.RequestTimeout < 0 { + return fmt.Errorf("target %d: request_timeout must be positive", idx) + } + + if opts.SessionIdleTimeout < 0 { + return fmt.Errorf("target %d: session_idle_timeout must be positive", idx) + } + + if err := validateCustomHeaders(idx, opts.CustomHeaders); err != nil { + return err + } + + return nil +} + +func validateCustomHeaders(idx int, headers map[string]string) error { + if len(headers) > maxCustomHeaders { + return fmt.Errorf("target %d: custom_headers count %d exceeds maximum of %d", idx, len(headers), maxCustomHeaders) + } + seen := make(map[string]string, len(headers)) + for key, value := range headers { + if !httpHeaderNameRe.MatchString(key) { + return fmt.Errorf("target %d: custom header key %q is not a valid HTTP header name", idx, key) + } + if len(key) > maxHeaderKeyLen { + return fmt.Errorf("target %d: custom header key %q exceeds maximum length of %d", idx, key, maxHeaderKeyLen) + } + if len(value) > maxHeaderValueLen { + return fmt.Errorf("target %d: custom header %q value exceeds maximum length of %d", idx, key, maxHeaderValueLen) + } + if containsCRLF(key) || containsCRLF(value) { + return fmt.Errorf("target %d: custom header %q contains invalid characters", idx, key) + } + canonical := http.CanonicalHeaderKey(key) + if prev, ok := seen[canonical]; ok { + return fmt.Errorf("target %d: custom header keys %q and %q collide (both canonicalize to %q)", idx, prev, key, canonical) + } + seen[canonical] = key + if _, ok := hopByHopHeaders[canonical]; ok { + return fmt.Errorf("target %d: custom header %q is a hop-by-hop header and cannot be set", idx, key) + } + if _, ok := reservedHeaders[canonical]; ok { + return fmt.Errorf("target %d: custom header %q is managed by the proxy and cannot be overridden", idx, key) + } + if canonical == "Host" { + return fmt.Errorf("target %d: use pass_host_header instead of setting Host as a custom header", idx) + } + } + return nil +} + +func containsCRLF(s string) bool { + return strings.ContainsAny(s, "\r\n") +} + +func validateHeaderAuths(headers []*HeaderAuthConfig) error { + seen := make(map[string]struct{}) + for i, h := range headers { + if h == nil || !h.Enabled { + continue + } + if h.Header == "" { + return fmt.Errorf("header_auths[%d]: header name is required", i) + } + if !httpHeaderNameRe.MatchString(h.Header) { + return fmt.Errorf("header_auths[%d]: header name %q is not a valid HTTP header name", i, h.Header) + } + canonical := http.CanonicalHeaderKey(h.Header) + if _, ok := hopByHopHeaders[canonical]; ok { + return fmt.Errorf("header_auths[%d]: header %q is a hop-by-hop header and cannot be used for auth", i, h.Header) + } + if _, ok := reservedHeaders[canonical]; ok { + return fmt.Errorf("header_auths[%d]: header %q is managed by the proxy and cannot be used for auth", i, h.Header) + } + if canonical == "Host" { + return fmt.Errorf("header_auths[%d]: Host header cannot be used for auth", i) + } + if _, dup := seen[canonical]; dup { + return fmt.Errorf("header_auths[%d]: duplicate header %q (same canonical form already configured)", i, h.Header) + } + seen[canonical] = struct{}{} + if len(h.Value) > maxHeaderValueLen { + return fmt.Errorf("header_auths[%d]: value exceeds maximum length of %d", i, maxHeaderValueLen) + } + } + return nil +} + +const ( + maxCIDREntries = 200 + maxCountryEntries = 50 +) + +// validateAccessRestrictions validates and normalizes access restriction +// entries. Country codes are uppercased in place. +func validateAccessRestrictions(r *AccessRestrictions) error { + if len(r.AllowedCIDRs) > maxCIDREntries { + return fmt.Errorf("allowed_cidrs: exceeds maximum of %d entries", maxCIDREntries) + } + if len(r.BlockedCIDRs) > maxCIDREntries { + return fmt.Errorf("blocked_cidrs: exceeds maximum of %d entries", maxCIDREntries) + } + if len(r.AllowedCountries) > maxCountryEntries { + return fmt.Errorf("allowed_countries: exceeds maximum of %d entries", maxCountryEntries) + } + if len(r.BlockedCountries) > maxCountryEntries { + return fmt.Errorf("blocked_countries: exceeds maximum of %d entries", maxCountryEntries) + } + + for i, raw := range r.AllowedCIDRs { + prefix, err := netip.ParsePrefix(raw) + if err != nil { + return fmt.Errorf("allowed_cidrs[%d]: %w", i, err) + } + if prefix != prefix.Masked() { + return fmt.Errorf("allowed_cidrs[%d]: %q has host bits set, use %s instead", i, raw, prefix.Masked()) + } + } + for i, raw := range r.BlockedCIDRs { + prefix, err := netip.ParsePrefix(raw) + if err != nil { + return fmt.Errorf("blocked_cidrs[%d]: %w", i, err) + } + if prefix != prefix.Masked() { + return fmt.Errorf("blocked_cidrs[%d]: %q has host bits set, use %s instead", i, raw, prefix.Masked()) + } + } + for i, code := range r.AllowedCountries { + if len(code) != 2 { + return fmt.Errorf("allowed_countries[%d]: %q must be a 2-letter ISO 3166-1 alpha-2 code", i, code) + } + r.AllowedCountries[i] = strings.ToUpper(code) + } + for i, code := range r.BlockedCountries { + if len(code) != 2 { + return fmt.Errorf("blocked_countries[%d]: %q must be a 2-letter ISO 3166-1 alpha-2 code", i, code) + } + r.BlockedCountries[i] = strings.ToUpper(code) + } + return nil +} + +func (s *Service) EventMeta() map[string]any { + meta := map[string]any{ + "name": s.Name, + "domain": s.Domain, + "proxy_cluster": s.ProxyCluster, + "source": s.Source, + "auth": s.isAuthEnabled(), + "mode": s.Mode, + } + + if s.ListenPort != 0 { + meta["listen_port"] = s.ListenPort + } + + if len(s.Targets) > 0 { + t := s.Targets[0] + if t.ProxyProtocol { + meta["proxy_protocol"] = true + } + if t.Options.RequestTimeout != 0 { + meta["request_timeout"] = t.Options.RequestTimeout.String() + } + if t.Options.SessionIdleTimeout != 0 { + meta["session_idle_timeout"] = t.Options.SessionIdleTimeout.String() + } + } + + return meta +} + +func (s *Service) isAuthEnabled() bool { + if (s.Auth.PasswordAuth != nil && s.Auth.PasswordAuth.Enabled) || + (s.Auth.PinAuth != nil && s.Auth.PinAuth.Enabled) || + (s.Auth.BearerAuth != nil && s.Auth.BearerAuth.Enabled) { + return true + } + for _, h := range s.Auth.HeaderAuths { + if h != nil && h.Enabled { + return true + } + } + return false +} + +func (s *Service) Copy() *Service { + targets := make([]*Target, len(s.Targets)) + for i, target := range s.Targets { + targetCopy := *target + if target.Path != nil { + p := *target.Path + targetCopy.Path = &p + } + if len(target.Options.CustomHeaders) > 0 { + targetCopy.Options.CustomHeaders = make(map[string]string, len(target.Options.CustomHeaders)) + for k, v := range target.Options.CustomHeaders { + targetCopy.Options.CustomHeaders[k] = v + } + } + targets[i] = &targetCopy + } + + authCopy := s.Auth + if s.Auth.PasswordAuth != nil { + pa := *s.Auth.PasswordAuth + authCopy.PasswordAuth = &pa + } + if s.Auth.PinAuth != nil { + pa := *s.Auth.PinAuth + authCopy.PinAuth = &pa + } + if s.Auth.BearerAuth != nil { + ba := *s.Auth.BearerAuth + if len(s.Auth.BearerAuth.DistributionGroups) > 0 { + ba.DistributionGroups = make([]string, len(s.Auth.BearerAuth.DistributionGroups)) + copy(ba.DistributionGroups, s.Auth.BearerAuth.DistributionGroups) + } + authCopy.BearerAuth = &ba + } + if len(s.Auth.HeaderAuths) > 0 { + authCopy.HeaderAuths = make([]*HeaderAuthConfig, len(s.Auth.HeaderAuths)) + for i, h := range s.Auth.HeaderAuths { + if h == nil { + continue + } + hCopy := *h + authCopy.HeaderAuths[i] = &hCopy + } + } + + return &Service{ + ID: s.ID, + AccountID: s.AccountID, + Name: s.Name, + Domain: s.Domain, + ProxyCluster: s.ProxyCluster, + Targets: targets, + Enabled: s.Enabled, + PassHostHeader: s.PassHostHeader, + RewriteRedirects: s.RewriteRedirects, + Auth: authCopy, + Restrictions: s.Restrictions.Copy(), + Meta: s.Meta, + SessionPrivateKey: s.SessionPrivateKey, + SessionPublicKey: s.SessionPublicKey, + Source: s.Source, + SourcePeer: s.SourcePeer, + Mode: s.Mode, + ListenPort: s.ListenPort, + PortAutoAssigned: s.PortAutoAssigned, + } +} + +func (s *Service) EncryptSensitiveData(enc *crypt.FieldEncrypt) error { + if enc == nil { + return nil + } + + if s.SessionPrivateKey != "" { + var err error + s.SessionPrivateKey, err = enc.Encrypt(s.SessionPrivateKey) + if err != nil { + return err + } + } + + return nil +} + +func (s *Service) DecryptSensitiveData(enc *crypt.FieldEncrypt) error { + if enc == nil { + return nil + } + + if s.SessionPrivateKey != "" { + var err error + s.SessionPrivateKey, err = enc.Decrypt(s.SessionPrivateKey) + if err != nil { + return err + } + } + + return nil +} + +var pinRegexp = regexp.MustCompile(`^\d{6}$`) + +const alphanumCharset = "abcdefghijklmnopqrstuvwxyz0123456789" + +var validNamePrefix = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$`) + +// ExposeServiceRequest contains the parameters for creating a peer-initiated expose service. +type ExposeServiceRequest struct { + NamePrefix string + Port uint16 + Mode string + // TargetProtocol is the protocol used to connect to the peer backend. + // For HTTP mode: "http" (default) or "https". For L4 modes: "tcp" or "udp". + TargetProtocol string + Domain string + Pin string + Password string + UserGroups []string + ListenPort uint16 +} + +// Validate checks all fields of the expose request. +func (r *ExposeServiceRequest) Validate() error { + if r == nil { + return errors.New("request cannot be nil") + } + + if r.Port == 0 { + return fmt.Errorf("port must be between 1 and 65535, got %d", r.Port) + } + + switch r.Mode { + case ModeHTTP, ModeTCP, ModeUDP, ModeTLS: + default: + return fmt.Errorf("unsupported mode %q", r.Mode) + } + + if IsL4Protocol(r.Mode) { + if r.Pin != "" || r.Password != "" || len(r.UserGroups) > 0 { + return fmt.Errorf("authentication is not supported for %s mode", r.Mode) + } + } + + if r.Pin != "" && !pinRegexp.MatchString(r.Pin) { + return errors.New("invalid pin: must be exactly 6 digits") + } + + for _, g := range r.UserGroups { + if g == "" { + return errors.New("user group name cannot be empty") + } + } + + if r.NamePrefix != "" && !validNamePrefix.MatchString(r.NamePrefix) { + return fmt.Errorf("invalid name prefix %q: must be lowercase alphanumeric with optional hyphens, 1-32 characters", r.NamePrefix) + } + + return nil +} + +// ToService builds a Service from the expose request. +func (r *ExposeServiceRequest) ToService(accountID, peerID, serviceName string) *Service { + svc := &Service{ + AccountID: accountID, + Name: serviceName, + Mode: r.Mode, + Enabled: true, + } + + // If domain is empty, CreateServiceFromPeer generates a unique subdomain. + // When explicitly provided, the service name is prepended as a subdomain. + if r.Domain != "" { + svc.Domain = serviceName + "." + r.Domain + } + + if IsL4Protocol(r.Mode) { + svc.ListenPort = r.Port + if r.ListenPort > 0 { + svc.ListenPort = r.ListenPort + } + } + + var targetProto string + switch { + case !IsL4Protocol(r.Mode): + targetProto = TargetProtoHTTP + if r.TargetProtocol != "" { + targetProto = r.TargetProtocol + } + case r.Mode == ModeUDP: + targetProto = TargetProtoUDP + default: + targetProto = TargetProtoTCP + } + svc.Targets = []*Target{ + { + AccountID: accountID, + Port: r.Port, + Protocol: targetProto, + TargetId: peerID, + TargetType: TargetTypePeer, + Enabled: true, + }, + } + + if r.Pin != "" { + svc.Auth.PinAuth = &PINAuthConfig{ + Enabled: true, + Pin: r.Pin, + } + } + + if r.Password != "" { + svc.Auth.PasswordAuth = &PasswordAuthConfig{ + Enabled: true, + Password: r.Password, + } + } + + if len(r.UserGroups) > 0 { + svc.Auth.BearerAuth = &BearerAuthConfig{ + Enabled: true, + DistributionGroups: r.UserGroups, + } + } + + return svc +} + +// ExposeServiceResponse contains the result of a successful peer expose creation. +type ExposeServiceResponse struct { + ServiceName string + ServiceURL string + Domain string + PortAutoAssigned bool +} + +// GenerateExposeName generates a random service name for peer-exposed services. +// The prefix, if provided, must be a valid DNS label component (lowercase alphanumeric and hyphens). +func GenerateExposeName(prefix string) (string, error) { + if prefix != "" && !validNamePrefix.MatchString(prefix) { + return "", fmt.Errorf("invalid name prefix %q: must be lowercase alphanumeric with optional hyphens, 1-32 characters", prefix) + } + + suffixLen := 12 + if prefix != "" { + suffixLen = 4 + } + + suffix, err := randomAlphanumeric(suffixLen) + if err != nil { + return "", fmt.Errorf("generate random name: %w", err) + } + + if prefix == "" { + return suffix, nil + } + return prefix + "-" + suffix, nil +} + +func randomAlphanumeric(n int) (string, error) { + result := make([]byte, n) + charsetLen := big.NewInt(int64(len(alphanumCharset))) + for i := range result { + idx, err := rand.Int(rand.Reader, charsetLen) + if err != nil { + return "", err + } + result[i] = alphanumCharset[idx.Int64()] + } + return string(result), nil +} diff --git a/management/internals/modules/reverseproxy/service/service_test.go b/management/internals/modules/reverseproxy/service/service_test.go new file mode 100644 index 000000000..9daf729fe --- /dev/null +++ b/management/internals/modules/reverseproxy/service/service_test.go @@ -0,0 +1,911 @@ +package service + +import ( + "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + "github.com/netbirdio/netbird/shared/hash/argon2id" + "github.com/netbirdio/netbird/shared/management/proto" +) + +func validProxy() *Service { + return &Service{ + Name: "test", + Domain: "example.com", + Targets: []*Target{ + {TargetId: "peer-1", TargetType: TargetTypePeer, Host: "10.0.0.1", Port: 80, Protocol: "http", Enabled: true}, + }, + } +} + +func TestValidate_Valid(t *testing.T) { + require.NoError(t, validProxy().Validate()) +} + +func TestValidate_EmptyName(t *testing.T) { + rp := validProxy() + rp.Name = "" + assert.ErrorContains(t, rp.Validate(), "name is required") +} + +func TestValidate_EmptyDomain(t *testing.T) { + rp := validProxy() + rp.Domain = "" + assert.ErrorContains(t, rp.Validate(), "domain is required") +} + +func TestValidate_NoTargets(t *testing.T) { + rp := validProxy() + rp.Targets = nil + assert.ErrorContains(t, rp.Validate(), "at least one target is required") +} + +func TestValidate_EmptyTargetId(t *testing.T) { + rp := validProxy() + rp.Targets[0].TargetId = "" + assert.ErrorContains(t, rp.Validate(), "empty target_id") +} + +func TestValidate_InvalidTargetType(t *testing.T) { + rp := validProxy() + rp.Targets[0].TargetType = "invalid" + assert.ErrorContains(t, rp.Validate(), "invalid target_type") +} + +func TestValidate_ResourceTarget(t *testing.T) { + rp := validProxy() + rp.Targets = append(rp.Targets, &Target{ + TargetId: "resource-1", + TargetType: TargetTypeHost, + Host: "example.org", + Port: 443, + Protocol: "https", + Enabled: true, + }) + require.NoError(t, rp.Validate()) +} + +func TestValidate_MultipleTargetsOneInvalid(t *testing.T) { + rp := validProxy() + rp.Targets = append(rp.Targets, &Target{ + TargetId: "", + TargetType: TargetTypePeer, + Host: "10.0.0.2", + Port: 80, + Protocol: "http", + Enabled: true, + }) + err := rp.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "target 1") + assert.Contains(t, err.Error(), "empty target_id") +} + +func TestValidateTargetOptions_PathRewrite(t *testing.T) { + tests := []struct { + name string + mode PathRewriteMode + wantErr string + }{ + {"empty is default", "", ""}, + {"preserve is valid", PathRewritePreserve, ""}, + {"unknown rejected", "regex", "unknown path_rewrite mode"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rp := validProxy() + rp.Targets[0].Options.PathRewrite = tt.mode + err := rp.Validate() + if tt.wantErr == "" { + assert.NoError(t, err) + } else { + assert.ErrorContains(t, err, tt.wantErr) + } + }) + } +} + +func TestValidateTargetOptions_RequestTimeout(t *testing.T) { + tests := []struct { + name string + timeout time.Duration + wantErr string + }{ + {"valid 30s", 30 * time.Second, ""}, + {"valid 2m", 2 * time.Minute, ""}, + {"valid 10m", 10 * time.Minute, ""}, + {"zero is fine", 0, ""}, + {"negative", -1 * time.Second, "must be positive"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rp := validProxy() + rp.Targets[0].Options.RequestTimeout = tt.timeout + err := rp.Validate() + if tt.wantErr == "" { + assert.NoError(t, err) + } else { + assert.ErrorContains(t, err, tt.wantErr) + } + }) + } +} + +func TestValidateTargetOptions_CustomHeaders(t *testing.T) { + t.Run("valid headers", func(t *testing.T) { + rp := validProxy() + rp.Targets[0].Options.CustomHeaders = map[string]string{ + "X-Custom": "value", + "X-Trace": "abc123", + } + assert.NoError(t, rp.Validate()) + }) + + t.Run("CRLF in key", func(t *testing.T) { + rp := validProxy() + rp.Targets[0].Options.CustomHeaders = map[string]string{"X-Bad\r\nKey": "value"} + assert.ErrorContains(t, rp.Validate(), "not a valid HTTP header name") + }) + + t.Run("CRLF in value", func(t *testing.T) { + rp := validProxy() + rp.Targets[0].Options.CustomHeaders = map[string]string{"X-Good": "bad\nvalue"} + assert.ErrorContains(t, rp.Validate(), "invalid characters") + }) + + t.Run("hop-by-hop header rejected", func(t *testing.T) { + for _, h := range []string{"Connection", "Transfer-Encoding", "Keep-Alive", "Upgrade", "Proxy-Connection"} { + rp := validProxy() + rp.Targets[0].Options.CustomHeaders = map[string]string{h: "value"} + assert.ErrorContains(t, rp.Validate(), "hop-by-hop", "header %q should be rejected", h) + } + }) + + t.Run("reserved header rejected", func(t *testing.T) { + for _, h := range []string{"X-Forwarded-For", "X-Real-IP", "X-Forwarded-Proto", "X-Forwarded-Host", "X-Forwarded-Port", "Cookie", "Forwarded", "Content-Length", "Content-Type"} { + rp := validProxy() + rp.Targets[0].Options.CustomHeaders = map[string]string{h: "value"} + assert.ErrorContains(t, rp.Validate(), "managed by the proxy", "header %q should be rejected", h) + } + }) + + t.Run("Host header rejected", func(t *testing.T) { + rp := validProxy() + rp.Targets[0].Options.CustomHeaders = map[string]string{"Host": "evil.com"} + assert.ErrorContains(t, rp.Validate(), "pass_host_header") + }) + + t.Run("too many headers", func(t *testing.T) { + rp := validProxy() + headers := make(map[string]string, 17) + for i := range 17 { + headers[fmt.Sprintf("X-H%d", i)] = "v" + } + rp.Targets[0].Options.CustomHeaders = headers + assert.ErrorContains(t, rp.Validate(), "exceeds maximum of 16") + }) + + t.Run("key too long", func(t *testing.T) { + rp := validProxy() + rp.Targets[0].Options.CustomHeaders = map[string]string{strings.Repeat("X", 129): "v"} + assert.ErrorContains(t, rp.Validate(), "key") + assert.ErrorContains(t, rp.Validate(), "exceeds maximum length") + }) + + t.Run("value too long", func(t *testing.T) { + rp := validProxy() + rp.Targets[0].Options.CustomHeaders = map[string]string{"X-Ok": strings.Repeat("v", 4097)} + assert.ErrorContains(t, rp.Validate(), "value exceeds maximum length") + }) + + t.Run("duplicate canonical keys rejected", func(t *testing.T) { + rp := validProxy() + rp.Targets[0].Options.CustomHeaders = map[string]string{ + "x-custom": "a", + "X-Custom": "b", + } + assert.ErrorContains(t, rp.Validate(), "collide") + }) +} + +func TestToProtoMapping_TargetOptions(t *testing.T) { + rp := &Service{ + ID: "svc-1", + AccountID: "acc-1", + Domain: "example.com", + Targets: []*Target{ + { + TargetId: "peer-1", + TargetType: TargetTypePeer, + Host: "10.0.0.1", + Port: 8080, + Protocol: "http", + Enabled: true, + Options: TargetOptions{ + SkipTLSVerify: true, + RequestTimeout: 30 * time.Second, + PathRewrite: PathRewritePreserve, + CustomHeaders: map[string]string{"X-Custom": "val"}, + }, + }, + }, + } + pm := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{}) + require.Len(t, pm.Path, 1) + + opts := pm.Path[0].Options + require.NotNil(t, opts, "options should be populated") + assert.True(t, opts.SkipTlsVerify) + assert.Equal(t, proto.PathRewriteMode_PATH_REWRITE_PRESERVE, opts.PathRewrite) + assert.Equal(t, map[string]string{"X-Custom": "val"}, opts.CustomHeaders) + require.NotNil(t, opts.RequestTimeout) + assert.Equal(t, int64(30), opts.RequestTimeout.Seconds) +} + +func TestToProtoMapping_NoOptionsWhenDefault(t *testing.T) { + rp := &Service{ + ID: "svc-1", + AccountID: "acc-1", + Domain: "example.com", + Targets: []*Target{ + { + TargetId: "peer-1", + TargetType: TargetTypePeer, + Host: "10.0.0.1", + Port: 8080, + Protocol: "http", + Enabled: true, + }, + }, + } + pm := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{}) + require.Len(t, pm.Path, 1) + assert.Nil(t, pm.Path[0].Options, "options should be nil when all defaults") +} + +func TestIsDefaultPort(t *testing.T) { + tests := []struct { + scheme string + port uint16 + want bool + }{ + {"http", 80, true}, + {"https", 443, true}, + {"http", 443, false}, + {"https", 80, false}, + {"http", 8080, false}, + {"https", 8443, false}, + {"http", 0, false}, + {"https", 0, false}, + } + for _, tt := range tests { + t.Run(fmt.Sprintf("%s/%d", tt.scheme, tt.port), func(t *testing.T) { + assert.Equal(t, tt.want, isDefaultPort(tt.scheme, tt.port)) + }) + } +} + +func TestToProtoMapping_PortInTargetURL(t *testing.T) { + oidcConfig := proxy.OIDCValidationConfig{} + + tests := []struct { + name string + protocol string + host string + port uint16 + wantTarget string + }{ + { + name: "http with default port 80 omits port", + protocol: "http", + host: "10.0.0.1", + port: 80, + wantTarget: "http://10.0.0.1/", + }, + { + name: "https with default port 443 omits port", + protocol: "https", + host: "10.0.0.1", + port: 443, + wantTarget: "https://10.0.0.1/", + }, + { + name: "port 0 omits port", + protocol: "http", + host: "10.0.0.1", + port: 0, + wantTarget: "http://10.0.0.1/", + }, + { + name: "non-default port is included", + protocol: "http", + host: "10.0.0.1", + port: 8080, + wantTarget: "http://10.0.0.1:8080/", + }, + { + name: "https with non-default port is included", + protocol: "https", + host: "10.0.0.1", + port: 8443, + wantTarget: "https://10.0.0.1:8443/", + }, + { + name: "http port 443 is included", + protocol: "http", + host: "10.0.0.1", + port: 443, + wantTarget: "http://10.0.0.1:443/", + }, + { + name: "https port 80 is included", + protocol: "https", + host: "10.0.0.1", + port: 80, + wantTarget: "https://10.0.0.1:80/", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rp := &Service{ + ID: "test-id", + AccountID: "acc-1", + Domain: "example.com", + Targets: []*Target{ + { + TargetId: "peer-1", + TargetType: TargetTypePeer, + Host: tt.host, + Port: tt.port, + Protocol: tt.protocol, + Enabled: true, + }, + }, + } + pm := rp.ToProtoMapping(Create, "token", oidcConfig) + require.Len(t, pm.Path, 1, "should have one path mapping") + assert.Equal(t, tt.wantTarget, pm.Path[0].Target) + }) + } +} + +func TestToProtoMapping_DisabledTargetSkipped(t *testing.T) { + rp := &Service{ + ID: "test-id", + AccountID: "acc-1", + Domain: "example.com", + Targets: []*Target{ + {TargetId: "peer-1", TargetType: TargetTypePeer, Host: "10.0.0.1", Port: 8080, Protocol: "http", Enabled: false}, + {TargetId: "peer-2", TargetType: TargetTypePeer, Host: "10.0.0.2", Port: 9090, Protocol: "http", Enabled: true}, + }, + } + pm := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{}) + require.Len(t, pm.Path, 1) + assert.Equal(t, "http://10.0.0.2:9090/", pm.Path[0].Target) +} + +func TestToProtoMapping_OperationTypes(t *testing.T) { + rp := validProxy() + tests := []struct { + op Operation + want proto.ProxyMappingUpdateType + }{ + {Create, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED}, + {Update, proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED}, + {Delete, proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED}, + } + for _, tt := range tests { + t.Run(string(tt.op), func(t *testing.T) { + pm := rp.ToProtoMapping(tt.op, "", proxy.OIDCValidationConfig{}) + assert.Equal(t, tt.want, pm.Type) + }) + } +} + +func TestAuthConfig_HashSecrets(t *testing.T) { + tests := []struct { + name string + config *AuthConfig + wantErr bool + validate func(*testing.T, *AuthConfig) + }{ + { + name: "hash password successfully", + config: &AuthConfig{ + PasswordAuth: &PasswordAuthConfig{ + Enabled: true, + Password: "testPassword123", + }, + }, + wantErr: false, + validate: func(t *testing.T, config *AuthConfig) { + if !strings.HasPrefix(config.PasswordAuth.Password, "$argon2id$") { + t.Errorf("Password not hashed with argon2id, got: %s", config.PasswordAuth.Password) + } + // Verify the hash can be verified + if err := argon2id.Verify("testPassword123", config.PasswordAuth.Password); err != nil { + t.Errorf("Hash verification failed: %v", err) + } + }, + }, + { + name: "hash PIN successfully", + config: &AuthConfig{ + PinAuth: &PINAuthConfig{ + Enabled: true, + Pin: "123456", + }, + }, + wantErr: false, + validate: func(t *testing.T, config *AuthConfig) { + if !strings.HasPrefix(config.PinAuth.Pin, "$argon2id$") { + t.Errorf("PIN not hashed with argon2id, got: %s", config.PinAuth.Pin) + } + // Verify the hash can be verified + if err := argon2id.Verify("123456", config.PinAuth.Pin); err != nil { + t.Errorf("Hash verification failed: %v", err) + } + }, + }, + { + name: "hash both password and PIN", + config: &AuthConfig{ + PasswordAuth: &PasswordAuthConfig{ + Enabled: true, + Password: "password", + }, + PinAuth: &PINAuthConfig{ + Enabled: true, + Pin: "9999", + }, + }, + wantErr: false, + validate: func(t *testing.T, config *AuthConfig) { + if !strings.HasPrefix(config.PasswordAuth.Password, "$argon2id$") { + t.Errorf("Password not hashed with argon2id") + } + if !strings.HasPrefix(config.PinAuth.Pin, "$argon2id$") { + t.Errorf("PIN not hashed with argon2id") + } + if err := argon2id.Verify("password", config.PasswordAuth.Password); err != nil { + t.Errorf("Password hash verification failed: %v", err) + } + if err := argon2id.Verify("9999", config.PinAuth.Pin); err != nil { + t.Errorf("PIN hash verification failed: %v", err) + } + }, + }, + { + name: "skip disabled password auth", + config: &AuthConfig{ + PasswordAuth: &PasswordAuthConfig{ + Enabled: false, + Password: "password", + }, + }, + wantErr: false, + validate: func(t *testing.T, config *AuthConfig) { + if config.PasswordAuth.Password != "password" { + t.Errorf("Disabled password auth should not be hashed") + } + }, + }, + { + name: "skip empty password", + config: &AuthConfig{ + PasswordAuth: &PasswordAuthConfig{ + Enabled: true, + Password: "", + }, + }, + wantErr: false, + validate: func(t *testing.T, config *AuthConfig) { + if config.PasswordAuth.Password != "" { + t.Errorf("Empty password should remain empty") + } + }, + }, + { + name: "skip nil password auth", + config: &AuthConfig{ + PasswordAuth: nil, + PinAuth: &PINAuthConfig{ + Enabled: true, + Pin: "1234", + }, + }, + wantErr: false, + validate: func(t *testing.T, config *AuthConfig) { + if config.PasswordAuth != nil { + t.Errorf("PasswordAuth should remain nil") + } + if !strings.HasPrefix(config.PinAuth.Pin, "$argon2id$") { + t.Errorf("PIN should still be hashed") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.HashSecrets() + if (err != nil) != tt.wantErr { + t.Errorf("HashSecrets() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.validate != nil { + tt.validate(t, tt.config) + } + }) + } +} + +func TestAuthConfig_HashSecrets_VerifyIncorrectSecret(t *testing.T) { + config := &AuthConfig{ + PasswordAuth: &PasswordAuthConfig{ + Enabled: true, + Password: "correctPassword", + }, + } + + if err := config.HashSecrets(); err != nil { + t.Fatalf("HashSecrets() error = %v", err) + } + + // Verify with wrong password should fail + err := argon2id.Verify("wrongPassword", config.PasswordAuth.Password) + if !errors.Is(err, argon2id.ErrMismatchedHashAndPassword) { + t.Errorf("Expected ErrMismatchedHashAndPassword, got %v", err) + } +} + +func TestAuthConfig_ClearSecrets(t *testing.T) { + config := &AuthConfig{ + PasswordAuth: &PasswordAuthConfig{ + Enabled: true, + Password: "hashedPassword", + }, + PinAuth: &PINAuthConfig{ + Enabled: true, + Pin: "hashedPin", + }, + } + + config.ClearSecrets() + + if config.PasswordAuth.Password != "" { + t.Errorf("Password not cleared, got: %s", config.PasswordAuth.Password) + } + if config.PinAuth.Pin != "" { + t.Errorf("PIN not cleared, got: %s", config.PinAuth.Pin) + } +} + +func TestGenerateExposeName(t *testing.T) { + t.Run("no prefix generates 12-char name", func(t *testing.T) { + name, err := GenerateExposeName("") + require.NoError(t, err) + assert.Len(t, name, 12) + assert.Regexp(t, `^[a-z0-9]+$`, name) + }) + + t.Run("with prefix generates prefix-XXXX", func(t *testing.T) { + name, err := GenerateExposeName("myapp") + require.NoError(t, err) + assert.True(t, strings.HasPrefix(name, "myapp-"), "name should start with prefix") + suffix := strings.TrimPrefix(name, "myapp-") + assert.Len(t, suffix, 4, "suffix should be 4 chars") + assert.Regexp(t, `^[a-z0-9]+$`, suffix) + }) + + t.Run("unique names", func(t *testing.T) { + names := make(map[string]bool) + for i := 0; i < 50; i++ { + name, err := GenerateExposeName("") + require.NoError(t, err) + names[name] = true + } + assert.Greater(t, len(names), 45, "should generate mostly unique names") + }) + + t.Run("valid prefixes", func(t *testing.T) { + validPrefixes := []string{"a", "ab", "a1", "my-app", "web-server-01", "a-b"} + for _, prefix := range validPrefixes { + name, err := GenerateExposeName(prefix) + assert.NoError(t, err, "prefix %q should be valid", prefix) + assert.True(t, strings.HasPrefix(name, prefix+"-"), "name should start with %q-", prefix) + } + }) + + t.Run("invalid prefixes", func(t *testing.T) { + invalidPrefixes := []string{ + "-starts-with-dash", + "ends-with-dash-", + "has.dots", + "HAS-UPPER", + "has spaces", + "has/slash", + "a--", + } + for _, prefix := range invalidPrefixes { + _, err := GenerateExposeName(prefix) + assert.Error(t, err, "prefix %q should be invalid", prefix) + assert.Contains(t, err.Error(), "invalid name prefix") + } + }) +} + +func TestExposeServiceRequest_ToService(t *testing.T) { + t.Run("basic HTTP service", func(t *testing.T) { + req := &ExposeServiceRequest{ + Port: 8080, + Mode: "http", + } + + service := req.ToService("account-1", "peer-1", "mysvc") + + assert.Equal(t, "account-1", service.AccountID) + assert.Equal(t, "mysvc", service.Name) + assert.True(t, service.Enabled) + assert.Empty(t, service.Domain, "domain should be empty when not specified") + require.Len(t, service.Targets, 1) + + target := service.Targets[0] + assert.Equal(t, uint16(8080), target.Port) + assert.Equal(t, "http", target.Protocol) + assert.Equal(t, "peer-1", target.TargetId) + assert.Equal(t, TargetTypePeer, target.TargetType) + assert.True(t, target.Enabled) + assert.Equal(t, "account-1", target.AccountID) + }) + + t.Run("with custom domain", func(t *testing.T) { + req := &ExposeServiceRequest{ + Port: 3000, + Domain: "example.com", + } + + service := req.ToService("acc", "peer", "web") + assert.Equal(t, "web.example.com", service.Domain) + }) + + t.Run("with PIN auth", func(t *testing.T) { + req := &ExposeServiceRequest{ + Port: 80, + Pin: "1234", + } + + service := req.ToService("acc", "peer", "svc") + require.NotNil(t, service.Auth.PinAuth) + assert.True(t, service.Auth.PinAuth.Enabled) + assert.Equal(t, "1234", service.Auth.PinAuth.Pin) + assert.Nil(t, service.Auth.PasswordAuth) + assert.Nil(t, service.Auth.BearerAuth) + }) + + t.Run("with password auth", func(t *testing.T) { + req := &ExposeServiceRequest{ + Port: 80, + Password: "secret", + } + + service := req.ToService("acc", "peer", "svc") + require.NotNil(t, service.Auth.PasswordAuth) + assert.True(t, service.Auth.PasswordAuth.Enabled) + assert.Equal(t, "secret", service.Auth.PasswordAuth.Password) + }) + + t.Run("with user groups (bearer auth)", func(t *testing.T) { + req := &ExposeServiceRequest{ + Port: 80, + UserGroups: []string{"admins", "devs"}, + } + + service := req.ToService("acc", "peer", "svc") + require.NotNil(t, service.Auth.BearerAuth) + assert.True(t, service.Auth.BearerAuth.Enabled) + assert.Equal(t, []string{"admins", "devs"}, service.Auth.BearerAuth.DistributionGroups) + }) + + t.Run("with all auth types", func(t *testing.T) { + req := &ExposeServiceRequest{ + Port: 443, + Domain: "myco.com", + Pin: "9999", + Password: "pass", + UserGroups: []string{"ops"}, + } + + service := req.ToService("acc", "peer", "full") + assert.Equal(t, "full.myco.com", service.Domain) + require.NotNil(t, service.Auth.PinAuth) + require.NotNil(t, service.Auth.PasswordAuth) + require.NotNil(t, service.Auth.BearerAuth) + }) +} + +func TestValidate_TLSOnly(t *testing.T) { + rp := &Service{ + Name: "tls-svc", + Mode: "tls", + Domain: "example.com", + ListenPort: 8443, + Targets: []*Target{ + {TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 443, Enabled: true}, + }, + } + require.NoError(t, rp.Validate()) +} + +func TestValidate_TLSMissingListenPort(t *testing.T) { + rp := &Service{ + Name: "tls-svc", + Mode: "tls", + Domain: "example.com", + ListenPort: 0, + Targets: []*Target{ + {TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 443, Enabled: true}, + }, + } + assert.ErrorContains(t, rp.Validate(), "listen_port is required") +} + +func TestValidate_TLSMissingDomain(t *testing.T) { + rp := &Service{ + Name: "tls-svc", + Mode: "tls", + ListenPort: 8443, + Targets: []*Target{ + {TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 443, Enabled: true}, + }, + } + assert.ErrorContains(t, rp.Validate(), "domain is required") +} + +func TestValidate_TCPValid(t *testing.T) { + rp := &Service{ + Name: "tcp-svc", + Mode: "tcp", + Domain: "cluster.test", + ListenPort: 5432, + Targets: []*Target{ + {TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 5432, Enabled: true}, + }, + } + require.NoError(t, rp.Validate()) +} + +func TestValidate_TCPMissingListenPort(t *testing.T) { + rp := &Service{ + Name: "tcp-svc", + Mode: "tcp", + Domain: "cluster.test", + Targets: []*Target{ + {TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 5432, Enabled: true}, + }, + } + require.NoError(t, rp.Validate(), "TCP with listen_port=0 is valid (auto-assigned by manager)") +} + +func TestValidate_L4MultipleTargets(t *testing.T) { + rp := &Service{ + Name: "tcp-svc", + Mode: "tcp", + Domain: "cluster.test", + ListenPort: 5432, + Targets: []*Target{ + {TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 5432, Enabled: true}, + {TargetId: "peer-2", TargetType: TargetTypePeer, Protocol: "tcp", Port: 5432, Enabled: true}, + }, + } + assert.ErrorContains(t, rp.Validate(), "exactly one target") +} + +func TestValidate_L4TargetMissingPort(t *testing.T) { + rp := &Service{ + Name: "tcp-svc", + Mode: "tcp", + Domain: "cluster.test", + ListenPort: 5432, + Targets: []*Target{ + {TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 0, Enabled: true}, + }, + } + assert.ErrorContains(t, rp.Validate(), "port is required") +} + +func TestValidate_TLSInvalidTargetType(t *testing.T) { + rp := &Service{ + Name: "tls-svc", + Mode: "tls", + Domain: "example.com", + ListenPort: 443, + Targets: []*Target{ + {TargetId: "peer-1", TargetType: "invalid", Protocol: "tcp", Port: 443, Enabled: true}, + }, + } + assert.Error(t, rp.Validate()) +} + +func TestValidate_TLSSubnetValid(t *testing.T) { + rp := &Service{ + Name: "tls-subnet", + Mode: "tls", + Domain: "example.com", + ListenPort: 8443, + Targets: []*Target{ + {TargetId: "subnet-1", TargetType: TargetTypeSubnet, Protocol: "tcp", Port: 443, Host: "10.0.0.5", Enabled: true}, + }, + } + require.NoError(t, rp.Validate()) +} + +func TestValidate_HTTPProxyProtocolRejected(t *testing.T) { + rp := validProxy() + rp.Targets[0].ProxyProtocol = true + assert.ErrorContains(t, rp.Validate(), "proxy_protocol is not supported for HTTP") +} + +func TestValidate_UDPProxyProtocolRejected(t *testing.T) { + rp := &Service{ + Name: "udp-svc", + Mode: "udp", + Domain: "cluster.test", + Targets: []*Target{ + {TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "udp", Port: 5432, Enabled: true, ProxyProtocol: true}, + }, + } + assert.ErrorContains(t, rp.Validate(), "proxy_protocol is not supported for UDP") +} + +func TestValidate_TCPProxyProtocolAllowed(t *testing.T) { + rp := &Service{ + Name: "tcp-svc", + Mode: "tcp", + Domain: "cluster.test", + ListenPort: 5432, + Targets: []*Target{ + {TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 5432, Enabled: true, ProxyProtocol: true}, + }, + } + require.NoError(t, rp.Validate()) +} + +func TestExposeServiceRequest_Validate_L4RejectsAuth(t *testing.T) { + tests := []struct { + name string + req ExposeServiceRequest + }{ + { + name: "tcp with pin", + req: ExposeServiceRequest{Port: 8080, Mode: "tcp", Pin: "123456"}, + }, + { + name: "udp with password", + req: ExposeServiceRequest{Port: 8080, Mode: "udp", Password: "secret"}, + }, + { + name: "tls with user groups", + req: ExposeServiceRequest{Port: 443, Mode: "tls", UserGroups: []string{"admins"}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.req.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "authentication is not supported") + }) + } +} + +func TestExposeServiceRequest_Validate_HTTPAllowsAuth(t *testing.T) { + req := ExposeServiceRequest{Port: 8080, Mode: "http", Pin: "123456"} + require.NoError(t, req.Validate()) +} diff --git a/management/internals/modules/reverseproxy/sessionkey/sessionkey.go b/management/internals/modules/reverseproxy/sessionkey/sessionkey.go new file mode 100644 index 000000000..aacbe5dca --- /dev/null +++ b/management/internals/modules/reverseproxy/sessionkey/sessionkey.go @@ -0,0 +1,69 @@ +package sessionkey + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" + + "github.com/netbirdio/netbird/proxy/auth" +) + +type KeyPair struct { + PrivateKey string + PublicKey string +} + +type Claims struct { + jwt.RegisteredClaims + Method auth.Method `json:"method"` +} + +func GenerateKeyPair() (*KeyPair, error) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("generate ed25519 key: %w", err) + } + + return &KeyPair{ + PrivateKey: base64.StdEncoding.EncodeToString(priv), + PublicKey: base64.StdEncoding.EncodeToString(pub), + }, nil +} + +func SignToken(privKeyB64, userID, domain string, method auth.Method, expiration time.Duration) (string, error) { + privKeyBytes, err := base64.StdEncoding.DecodeString(privKeyB64) + if err != nil { + return "", fmt.Errorf("decode private key: %w", err) + } + + if len(privKeyBytes) != ed25519.PrivateKeySize { + return "", fmt.Errorf("invalid private key size: got %d, want %d", len(privKeyBytes), ed25519.PrivateKeySize) + } + + privKey := ed25519.PrivateKey(privKeyBytes) + + now := time.Now() + claims := Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: auth.SessionJWTIssuer, + Subject: userID, + Audience: jwt.ClaimStrings{domain}, + ExpiresAt: jwt.NewNumericDate(now.Add(expiration)), + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + }, + Method: method, + } + + token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) + signedToken, err := token.SignedString(privKey) + if err != nil { + return "", fmt.Errorf("sign token: %w", err) + } + + return signedToken, nil +} diff --git a/management/internals/modules/zones/records/record.go b/management/internals/modules/zones/records/record.go index e44de08f4..1488febb9 100644 --- a/management/internals/modules/zones/records/record.go +++ b/management/internals/modules/zones/records/record.go @@ -6,7 +6,7 @@ import ( "github.com/rs/xid" - "github.com/netbirdio/netbird/management/server/util" + "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/management/http/api" ) @@ -63,7 +63,7 @@ func (r *Record) Validate() error { return errors.New("record name is required") } - if !util.IsValidDomain(r.Name) { + if !domain.IsValidDomain(r.Name) { return errors.New("invalid record name format") } @@ -81,8 +81,8 @@ func (r *Record) Validate() error { return err } case RecordTypeCNAME: - if !util.IsValidDomain(r.Content) { - return errors.New("invalid CNAME record format") + if !domain.IsValidDomainNoWildcard(r.Content) { + return errors.New("invalid CNAME target format") } default: return errors.New("invalid record type, must be A, AAAA, or CNAME") diff --git a/management/internals/modules/zones/zone.go b/management/internals/modules/zones/zone.go index 27adac1ac..f5ebed26c 100644 --- a/management/internals/modules/zones/zone.go +++ b/management/internals/modules/zones/zone.go @@ -6,7 +6,7 @@ import ( "github.com/rs/xid" "github.com/netbirdio/netbird/management/internals/modules/zones/records" - "github.com/netbirdio/netbird/management/server/util" + "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/management/http/api" ) @@ -73,7 +73,7 @@ func (z *Zone) Validate() error { return errors.New("zone name exceeds maximum length of 255 characters") } - if !util.IsValidDomain(z.Domain) { + if !domain.IsValidDomainNoWildcard(z.Domain) { return errors.New("invalid zone domain format") } diff --git a/management/internals/server/boot.go b/management/internals/server/boot.go index 55af17fdf..88d37ca80 100644 --- a/management/internals/server/boot.go +++ b/management/internals/server/boot.go @@ -19,8 +19,11 @@ import ( "google.golang.org/grpc/keepalive" "github.com/netbirdio/management-integrations/integrations" + "github.com/netbirdio/netbird/encryption" "github.com/netbirdio/netbird/formatter/hook" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + accesslogsmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs/manager" nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" "github.com/netbirdio/netbird/management/server/activity" nbContext "github.com/netbirdio/netbird/management/server/context" @@ -92,7 +95,7 @@ func (s *BaseServer) EventStore() activity.Store { func (s *BaseServer) APIHandler() http.Handler { return Create(s, func() http.Handler { - httpAPIHandler, err := nbhttp.NewAPIHandler(context.Background(), s.AccountManager(), s.NetworksManager(), s.ResourcesManager(), s.RoutesManager(), s.GroupsManager(), s.GeoLocationManager(), s.AuthManager(), s.Metrics(), s.IntegratedValidator(), s.ProxyController(), s.PermissionsManager(), s.PeersManager(), s.SettingsManager(), s.ZonesManager(), s.RecordsManager(), s.NetworkMapController(), s.IdpManager()) + httpAPIHandler, err := nbhttp.NewAPIHandler(context.Background(), s.AccountManager(), s.NetworksManager(), s.ResourcesManager(), s.RoutesManager(), s.GroupsManager(), s.GeoLocationManager(), s.AuthManager(), s.Metrics(), s.IntegratedValidator(), s.ProxyController(), s.PermissionsManager(), s.PeersManager(), s.SettingsManager(), s.ZonesManager(), s.RecordsManager(), s.NetworkMapController(), s.IdpManager(), s.ServiceManager(), s.ReverseProxyDomainManager(), s.AccessLogsManager(), s.ReverseProxyGRPCServer(), s.Config.ReverseProxy.TrustedHTTPProxies) if err != nil { log.Fatalf("failed to create API handler: %v", err) } @@ -120,17 +123,19 @@ func (s *BaseServer) GRPCServer() *grpc.Server { realip.WithTrustedProxiesCount(trustedProxiesCount), realip.WithHeaders([]string{realip.XForwardedFor, realip.XRealIp}), } + proxyUnary, proxyStream, proxyAuthClose := nbgrpc.NewProxyAuthInterceptors(s.Store()) + s.proxyAuthClose = proxyAuthClose gRPCOpts := []grpc.ServerOption{ grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp), - grpc.ChainUnaryInterceptor(realip.UnaryServerInterceptorOpts(realipOpts...), unaryInterceptor), - grpc.ChainStreamInterceptor(realip.StreamServerInterceptorOpts(realipOpts...), streamInterceptor), + grpc.ChainUnaryInterceptor(realip.UnaryServerInterceptorOpts(realipOpts...), unaryInterceptor, proxyUnary), + grpc.ChainStreamInterceptor(realip.StreamServerInterceptorOpts(realipOpts...), streamInterceptor, proxyStream), } if s.Config.HttpConfig.LetsEncryptDomain != "" { certManager, err := encryption.CreateCertManager(s.Config.Datadir, s.Config.HttpConfig.LetsEncryptDomain) if err != nil { - log.Fatalf("failed to create certificate manager: %v", err) + log.Fatalf("failed to create certificate service: %v", err) } transportCredentials := credentials.NewTLS(certManager.TLSConfig()) gRPCOpts = append(gRPCOpts, grpc.Creds(transportCredentials)) @@ -148,12 +153,79 @@ func (s *BaseServer) GRPCServer() *grpc.Server { if err != nil { log.Fatalf("failed to create management server: %v", err) } + serviceMgr := s.ServiceManager() + srv.SetReverseProxyManager(serviceMgr) + if serviceMgr != nil { + serviceMgr.StartExposeReaper(context.Background()) + } mgmtProto.RegisterManagementServiceServer(gRPCAPIHandler, srv) + mgmtProto.RegisterProxyServiceServer(gRPCAPIHandler, s.ReverseProxyGRPCServer()) + log.Info("ProxyService registered on gRPC server") + return gRPCAPIHandler }) } +func (s *BaseServer) ReverseProxyGRPCServer() *nbgrpc.ProxyServiceServer { + return Create(s, func() *nbgrpc.ProxyServiceServer { + proxyService := nbgrpc.NewProxyServiceServer(s.AccessLogsManager(), s.ProxyTokenStore(), s.PKCEVerifierStore(), s.proxyOIDCConfig(), s.PeersManager(), s.UsersManager(), s.ProxyManager()) + s.AfterInit(func(s *BaseServer) { + proxyService.SetServiceManager(s.ServiceManager()) + proxyService.SetProxyController(s.ServiceProxyController()) + }) + return proxyService + }) +} + +func (s *BaseServer) proxyOIDCConfig() nbgrpc.ProxyOIDCConfig { + return Create(s, func() nbgrpc.ProxyOIDCConfig { + return nbgrpc.ProxyOIDCConfig{ + Issuer: s.Config.HttpConfig.AuthIssuer, + // todo: double check auth clientID value + ClientID: s.Config.HttpConfig.AuthClientID, // Reuse dashboard client + Scopes: []string{"openid", "profile", "email"}, + CallbackURL: s.Config.HttpConfig.AuthCallbackURL, + HMACKey: []byte(s.Config.DataStoreEncryptionKey), // Use the datastore encryption key for OIDC state HMACs, this should ensure all management instances are using the same key. + Audience: s.Config.HttpConfig.AuthAudience, + KeysLocation: s.Config.HttpConfig.AuthKeysLocation, + } + }) +} + +func (s *BaseServer) ProxyTokenStore() *nbgrpc.OneTimeTokenStore { + return Create(s, func() *nbgrpc.OneTimeTokenStore { + tokenStore, err := nbgrpc.NewOneTimeTokenStore(context.Background(), 5*time.Minute, 10*time.Minute, 100) + if err != nil { + log.Fatalf("failed to create proxy token store: %v", err) + } + log.Info("One-time token store initialized for proxy authentication") + return tokenStore + }) +} + +func (s *BaseServer) PKCEVerifierStore() *nbgrpc.PKCEVerifierStore { + return Create(s, func() *nbgrpc.PKCEVerifierStore { + pkceStore, err := nbgrpc.NewPKCEVerifierStore(context.Background(), 10*time.Minute, 10*time.Minute, 100) + if err != nil { + log.Fatalf("failed to create PKCE verifier store: %v", err) + } + return pkceStore + }) +} + +func (s *BaseServer) AccessLogsManager() accesslogs.Manager { + return Create(s, func() accesslogs.Manager { + accessLogManager := accesslogsmanager.NewManager(s.Store(), s.PermissionsManager(), s.GeoLocationManager()) + accessLogManager.StartPeriodicCleanup( + context.Background(), + s.Config.ReverseProxy.AccessLogRetentionDays, + s.Config.ReverseProxy.AccessLogCleanupIntervalHours, + ) + return accessLogManager + }) +} + func loadTLSConfig(certFile string, certKey string) (*tls.Config, error) { // Load server's certificate and private key serverCert, err := tls.LoadX509KeyPair(certFile, certKey) diff --git a/management/internals/server/config/config.go b/management/internals/server/config/config.go index 7b8783943..0ba393263 100644 --- a/management/internals/server/config/config.go +++ b/management/internals/server/config/config.go @@ -100,6 +100,8 @@ type HttpServerConfig struct { CertFile string // CertKey is the location of the certificate private key CertKey string + // AuthClientID is the client id used for proxy SSO auth + AuthClientID string // AuthAudience identifies the recipients that the JWT is intended for (aud in JWT) AuthAudience string // CLIAuthAudience identifies the client app recipients that the JWT is intended for (aud in JWT) @@ -117,6 +119,8 @@ type HttpServerConfig struct { IdpSignKeyRefreshEnabled bool // Extra audience ExtraAuthAudience string + // AuthCallbackDomain contains the callback domain + AuthCallbackURL string } // Host represents a Netbird host (e.g. STUN, TURN, Signal) @@ -196,4 +200,13 @@ type ReverseProxy struct { // request headers if the peer's address falls within one of these // trusted IP prefixes. TrustedPeers []netip.Prefix + + // AccessLogRetentionDays specifies the number of days to retain access logs. + // Logs older than this duration will be automatically deleted during cleanup. + // A value of 0 or negative means logs are kept indefinitely (no cleanup). + AccessLogRetentionDays int + + // AccessLogCleanupIntervalHours specifies how often (in hours) to run the cleanup routine. + // Defaults to 24 hours if not set or set to 0. + AccessLogCleanupIntervalHours int } diff --git a/management/internals/server/controllers.go b/management/internals/server/controllers.go index 4ea86900a..62ed659c0 100644 --- a/management/internals/server/controllers.go +++ b/management/internals/server/controllers.go @@ -6,6 +6,8 @@ import ( log "github.com/sirupsen/logrus" "github.com/netbirdio/management-integrations/integrations" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager" "github.com/netbirdio/netbird/management/internals/controllers/network_map" nmapcontroller "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller" @@ -106,6 +108,16 @@ func (s *BaseServer) NetworkMapController() network_map.Controller { }) } +func (s *BaseServer) ServiceProxyController() proxy.Controller { + return Create(s, func() proxy.Controller { + controller, err := proxymanager.NewGRPCController(s.ReverseProxyGRPCServer(), s.Metrics().GetMeter()) + if err != nil { + log.Fatalf("failed to create service proxy controller: %v", err) + } + return controller + }) +} + func (s *BaseServer) AccountRequestBuffer() *server.AccountRequestBuffer { return Create(s, func() *server.AccountRequestBuffer { return server.NewAccountRequestBuffer(context.Background(), s.Store()) diff --git a/management/internals/server/modules.go b/management/internals/server/modules.go index b51e2ebb2..a32cf6046 100644 --- a/management/internals/server/modules.go +++ b/management/internals/server/modules.go @@ -7,7 +7,13 @@ import ( log "github.com/sirupsen/logrus" "github.com/netbirdio/management-integrations/integrations" + "github.com/netbirdio/netbird/management/internals/modules/peers" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain/manager" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + nbreverseproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service/manager" "github.com/netbirdio/netbird/management/internals/modules/zones" zonesManager "github.com/netbirdio/netbird/management/internals/modules/zones/manager" "github.com/netbirdio/netbird/management/internals/modules/zones/records" @@ -69,7 +75,14 @@ func (s *BaseServer) UsersManager() users.Manager { func (s *BaseServer) SettingsManager() settings.Manager { return Create(s, func() settings.Manager { extraSettingsManager := integrations.NewManager(s.EventStore()) - return settings.NewManager(s.Store(), s.UsersManager(), extraSettingsManager, s.PermissionsManager()) + + idpConfig := settings.IdpConfig{} + if s.Config.EmbeddedIdP != nil && s.Config.EmbeddedIdP.Enabled { + idpConfig.EmbeddedIdpEnabled = true + idpConfig.LocalAuthDisabled = s.Config.EmbeddedIdP.LocalAuthDisabled + } + + return settings.NewManager(s.Store(), s.UsersManager(), extraSettingsManager, s.PermissionsManager(), idpConfig) }) } @@ -89,8 +102,13 @@ func (s *BaseServer) AccountManager() account.Manager { return Create(s, func() account.Manager { accountManager, err := server.BuildManager(context.Background(), s.Config, s.Store(), s.NetworkMapController(), s.JobManager(), s.IdpManager(), s.mgmtSingleAccModeDomain, s.EventStore(), s.GeoLocationManager(), s.userDeleteFromIDPEnabled, s.IntegratedValidator(), s.Metrics(), s.ProxyController(), s.SettingsManager(), s.PermissionsManager(), s.Config.DisableDefaultPolicy) if err != nil { - log.Fatalf("failed to create account manager: %v", err) + log.Fatalf("failed to create account service: %v", err) } + + s.AfterInit(func(s *BaseServer) { + accountManager.SetServiceManager(s.ServiceManager()) + }) + return accountManager }) } @@ -99,28 +117,28 @@ func (s *BaseServer) IdpManager() idp.Manager { return Create(s, func() idp.Manager { var idpManager idp.Manager var err error - // Use embedded IdP manager if embedded Dex is configured and enabled. + // Use embedded IdP service if embedded Dex is configured and enabled. // Legacy IdpManager won't be used anymore even if configured. if s.Config.EmbeddedIdP != nil && s.Config.EmbeddedIdP.Enabled { idpManager, err = idp.NewEmbeddedIdPManager(context.Background(), s.Config.EmbeddedIdP, s.Metrics()) if err != nil { - log.Fatalf("failed to create embedded IDP manager: %v", err) + log.Fatalf("failed to create embedded IDP service: %v", err) } return idpManager } - // Fall back to external IdP manager + // Fall back to external IdP service if s.Config.IdpManagerConfig != nil { idpManager, err = idp.NewManager(context.Background(), *s.Config.IdpManagerConfig, s.Metrics()) if err != nil { - log.Fatalf("failed to create IDP manager: %v", err) + log.Fatalf("failed to create IDP service: %v", err) } } return idpManager }) } -// OAuthConfigProvider is only relevant when we have an embedded IdP manager. Otherwise must be nil +// OAuthConfigProvider is only relevant when we have an embedded IdP service. Otherwise must be nil func (s *BaseServer) OAuthConfigProvider() idp.OAuthConfigProvider { if s.Config.EmbeddedIdP == nil || !s.Config.EmbeddedIdP.Enabled { return nil @@ -147,7 +165,7 @@ func (s *BaseServer) GroupsManager() groups.Manager { func (s *BaseServer) ResourcesManager() resources.Manager { return Create(s, func() resources.Manager { - return resources.NewManager(s.Store(), s.PermissionsManager(), s.GroupsManager(), s.AccountManager()) + return resources.NewManager(s.Store(), s.PermissionsManager(), s.GroupsManager(), s.AccountManager(), s.ServiceManager()) }) } @@ -174,3 +192,29 @@ func (s *BaseServer) RecordsManager() records.Manager { return recordsManager.NewManager(s.Store(), s.AccountManager(), s.PermissionsManager()) }) } + +func (s *BaseServer) ServiceManager() service.Manager { + return Create(s, func() service.Manager { + return nbreverseproxy.NewManager(s.Store(), s.AccountManager(), s.PermissionsManager(), s.ServiceProxyController(), s.ReverseProxyDomainManager()) + }) +} + +func (s *BaseServer) ProxyManager() proxy.Manager { + return Create(s, func() proxy.Manager { + manager, err := proxymanager.NewManager(s.Store(), s.Metrics().GetMeter()) + if err != nil { + log.Fatalf("failed to create proxy manager: %v", err) + } + return manager + }) +} + +func (s *BaseServer) ReverseProxyDomainManager() *manager.Manager { + return Create(s, func() *manager.Manager { + m := manager.NewManager(s.Store(), s.ProxyManager(), s.PermissionsManager(), s.AccountManager()) + s.AfterInit(func(s *BaseServer) { + m.SetClusterCapabilities(s.ServiceProxyController()) + }) + return &m + }) +} diff --git a/management/internals/server/server.go b/management/internals/server/server.go index cd8d8e8fb..9b8716da1 100644 --- a/management/internals/server/server.go +++ b/management/internals/server/server.go @@ -11,7 +11,6 @@ import ( "time" "github.com/google/uuid" - "github.com/netbirdio/netbird/management/server/idp" log "github.com/sirupsen/logrus" "go.opentelemetry.io/otel/metric" "golang.org/x/crypto/acme/autocert" @@ -21,6 +20,7 @@ import ( "github.com/netbirdio/netbird/encryption" nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + "github.com/netbirdio/netbird/management/server/idp" "github.com/netbirdio/netbird/management/server/metrics" "github.com/netbirdio/netbird/management/server/store" "github.com/netbirdio/netbird/util/wsproxy" @@ -28,9 +28,13 @@ import ( "github.com/netbirdio/netbird/version" ) -// ManagementLegacyPort is the port that was used before by the Management gRPC server. -// It is used for backward compatibility now. -const ManagementLegacyPort = 33073 +const ( + // ManagementLegacyPort is the port that was used before by the Management gRPC server. + // It is used for backward compatibility now. + ManagementLegacyPort = 33073 + // DefaultSelfHostedDomain is the default domain used for self-hosted fresh installs. + DefaultSelfHostedDomain = "netbird.selfhosted" +) type Server interface { Start(ctx context.Context) error @@ -50,13 +54,17 @@ type BaseServer struct { // AfterInit is a function that will be called after the server is initialized afterInit []func(s *BaseServer) - disableMetrics bool - dnsDomain string - disableGeoliteUpdate bool - userDeleteFromIDPEnabled bool - mgmtSingleAccModeDomain string - mgmtMetricsPort int - mgmtPort int + disableMetrics bool + dnsDomain string + disableGeoliteUpdate bool + userDeleteFromIDPEnabled bool + mgmtSingleAccModeDomain string + mgmtMetricsPort int + mgmtPort int + disableLegacyManagementPort bool + autoResolveDomains bool + + proxyAuthClose func() listener net.Listener certManager *autocert.Manager @@ -67,18 +75,34 @@ type BaseServer struct { cancel context.CancelFunc } +// Config holds the configuration parameters for creating a new server +type Config struct { + NbConfig *nbconfig.Config + DNSDomain string + MgmtSingleAccModeDomain string + MgmtPort int + MgmtMetricsPort int + DisableLegacyManagementPort bool + DisableMetrics bool + DisableGeoliteUpdate bool + UserDeleteFromIDPEnabled bool + AutoResolveDomains bool +} + // NewServer initializes and configures a new Server instance -func NewServer(config *nbconfig.Config, dnsDomain, mgmtSingleAccModeDomain string, mgmtPort, mgmtMetricsPort int, disableMetrics, disableGeoliteUpdate, userDeleteFromIDPEnabled bool) *BaseServer { +func NewServer(cfg *Config) *BaseServer { return &BaseServer{ - Config: config, - container: make(map[string]any), - dnsDomain: dnsDomain, - mgmtSingleAccModeDomain: mgmtSingleAccModeDomain, - disableMetrics: disableMetrics, - disableGeoliteUpdate: disableGeoliteUpdate, - userDeleteFromIDPEnabled: userDeleteFromIDPEnabled, - mgmtPort: mgmtPort, - mgmtMetricsPort: mgmtMetricsPort, + Config: cfg.NbConfig, + container: make(map[string]any), + dnsDomain: cfg.DNSDomain, + mgmtSingleAccModeDomain: cfg.MgmtSingleAccModeDomain, + disableMetrics: cfg.DisableMetrics, + disableGeoliteUpdate: cfg.DisableGeoliteUpdate, + userDeleteFromIDPEnabled: cfg.UserDeleteFromIDPEnabled, + mgmtPort: cfg.MgmtPort, + disableLegacyManagementPort: cfg.DisableLegacyManagementPort, + mgmtMetricsPort: cfg.MgmtMetricsPort, + autoResolveDomains: cfg.AutoResolveDomains, } } @@ -92,6 +116,10 @@ func (s *BaseServer) Start(ctx context.Context) error { s.cancel = cancel s.errCh = make(chan error, 4) + if s.autoResolveDomains { + s.resolveDomains(srvCtx) + } + s.PeersManager() s.GeoLocationManager() @@ -138,8 +166,19 @@ func (s *BaseServer) Start(ctx context.Context) error { go metricsWorker.Run(srvCtx) } + // Eagerly create the gRPC server so that all AfterInit hooks are registered + // before we iterate them. Lazy creation after the loop would miss hooks + // registered during GRPCServer() construction (e.g., SetServiceManager). + s.GRPCServer() + + for _, fn := range s.afterInit { + if fn != nil { + fn(s) + } + } + var compatListener net.Listener - if s.mgmtPort != ManagementLegacyPort { + if s.mgmtPort != ManagementLegacyPort && !s.disableLegacyManagementPort { // The Management gRPC server was running on port 33073 previously. Old agents that are already connected to it // are using port 33073. For compatibility purposes we keep running a 2nd gRPC server on port 33073. compatListener, err = s.serveGRPC(srvCtx, s.GRPCServer(), ManagementLegacyPort) @@ -178,12 +217,6 @@ func (s *BaseServer) Start(ctx context.Context) error { } } - for _, fn := range s.afterInit { - if fn != nil { - fn(s) - } - } - log.WithContext(ctx).Infof("management server version %s", version.NetbirdVersion()) log.WithContext(ctx).Infof("running HTTP server and gRPC server on the same port: %s", s.listener.Addr().String()) s.serveGRPCWithHTTP(ctx, s.listener, rootHandler, tlsEnabled) @@ -215,6 +248,10 @@ func (s *BaseServer) Stop() error { _ = s.certManager.Listener().Close() } s.GRPCServer().Stop() + if s.proxyAuthClose != nil { + s.proxyAuthClose() + s.proxyAuthClose = nil + } _ = s.Store().Close(ctx) _ = s.EventStore().Close(ctx) if s.update != nil { @@ -255,7 +292,23 @@ func (s *BaseServer) SetContainer(key string, container any) { log.Tracef("container with key %s set successfully", key) } +// SetHandlerFunc allows overriding the default HTTP handler function. +// This is useful for multiplexing additional services on the same port. +func (s *BaseServer) SetHandlerFunc(handler http.Handler) { + s.container["customHandler"] = handler + log.Tracef("custom handler set successfully") +} + func (s *BaseServer) handlerFunc(_ context.Context, gRPCHandler *grpc.Server, httpHandler http.Handler, meter metric.Meter) http.Handler { + // Check if a custom handler was set (for multiplexing additional services) + if customHandler, ok := s.GetContainer("customHandler"); ok { + if handler, ok := customHandler.(http.Handler); ok { + log.Tracef("using custom handler") + return handler + } + } + + // Use default handler wsProxy := wsproxyserver.New(gRPCHandler, wsproxyserver.WithOTelMeter(meter)) return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { @@ -338,6 +391,60 @@ func (s *BaseServer) serveGRPCWithHTTP(ctx context.Context, listener net.Listene }() } +// resolveDomains determines dnsDomain and mgmtSingleAccModeDomain based on store state. +// Fresh installs use the default self-hosted domain, while existing installs reuse the +// persisted account domain to keep addressing stable across config changes. +func (s *BaseServer) resolveDomains(ctx context.Context) { + st := s.Store() + + setDefault := func(logMsg string, args ...any) { + if logMsg != "" { + log.WithContext(ctx).Warnf(logMsg, args...) + } + s.dnsDomain = DefaultSelfHostedDomain + s.mgmtSingleAccModeDomain = DefaultSelfHostedDomain + } + + accountsCount, err := st.GetAccountsCounter(ctx) + if err != nil { + setDefault("resolve domains: failed to read accounts counter: %v; using default domain %q", err, DefaultSelfHostedDomain) + return + } + + if accountsCount == 0 { + s.dnsDomain = DefaultSelfHostedDomain + s.mgmtSingleAccModeDomain = DefaultSelfHostedDomain + log.WithContext(ctx).Infof("resolve domains: fresh install detected, using default domain %q", DefaultSelfHostedDomain) + return + } + + accountID, err := st.GetAnyAccountID(ctx) + if err != nil { + setDefault("resolve domains: failed to get existing account ID: %v; using default domain %q", err, DefaultSelfHostedDomain) + return + } + + if accountID == "" { + setDefault("resolve domains: empty account ID returned for existing accounts; using default domain %q", DefaultSelfHostedDomain) + return + } + + domain, _, err := st.GetAccountDomainAndCategory(ctx, store.LockingStrengthNone, accountID) + if err != nil { + setDefault("resolve domains: failed to get account domain for account %q: %v; using default domain %q", accountID, err, DefaultSelfHostedDomain) + return + } + + if domain == "" { + setDefault("resolve domains: account %q has empty domain; using default domain %q", accountID, DefaultSelfHostedDomain) + return + } + + s.dnsDomain = domain + s.mgmtSingleAccModeDomain = domain + log.WithContext(ctx).Infof("resolve domains: using persisted account domain %q", domain) +} + func getInstallationID(ctx context.Context, store store.Store) (string, error) { installationID := store.GetInstallationID() if installationID != "" { diff --git a/management/internals/server/server_resolve_domains_test.go b/management/internals/server/server_resolve_domains_test.go new file mode 100644 index 000000000..db1d7e8ca --- /dev/null +++ b/management/internals/server/server_resolve_domains_test.go @@ -0,0 +1,63 @@ +package server + +import ( + "context" + "errors" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" + + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + "github.com/netbirdio/netbird/management/server/store" +) + +func TestResolveDomains_FreshInstallUsesDefault(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT().GetAccountsCounter(gomock.Any()).Return(int64(0), nil) + + srv := NewServer(&Config{NbConfig: &nbconfig.Config{}}) + Inject[store.Store](srv, mockStore) + + srv.resolveDomains(context.Background()) + + require.Equal(t, DefaultSelfHostedDomain, srv.dnsDomain) + require.Equal(t, DefaultSelfHostedDomain, srv.mgmtSingleAccModeDomain) +} + +func TestResolveDomains_ExistingInstallUsesPersistedDomain(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT().GetAccountsCounter(gomock.Any()).Return(int64(1), nil) + mockStore.EXPECT().GetAnyAccountID(gomock.Any()).Return("acc-1", nil) + mockStore.EXPECT().GetAccountDomainAndCategory(gomock.Any(), store.LockingStrengthNone, "acc-1").Return("vpn.mycompany.com", "", nil) + + srv := NewServer(&Config{NbConfig: &nbconfig.Config{}}) + Inject[store.Store](srv, mockStore) + + srv.resolveDomains(context.Background()) + + require.Equal(t, "vpn.mycompany.com", srv.dnsDomain) + require.Equal(t, "vpn.mycompany.com", srv.mgmtSingleAccModeDomain) +} + +func TestResolveDomains_StoreErrorFallsBackToDefault(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT().GetAccountsCounter(gomock.Any()).Return(int64(0), errors.New("db failed")) + + srv := NewServer(&Config{NbConfig: &nbconfig.Config{}}) + Inject[store.Store](srv, mockStore) + + srv.resolveDomains(context.Background()) + + require.Equal(t, DefaultSelfHostedDomain, srv.dnsDomain) + require.Equal(t, DefaultSelfHostedDomain, srv.mgmtSingleAccModeDomain) +} diff --git a/management/internals/shared/grpc/conversion.go b/management/internals/shared/grpc/conversion.go index c74fa2660..ef417d3cf 100644 --- a/management/internals/shared/grpc/conversion.go +++ b/management/internals/shared/grpc/conversion.go @@ -107,7 +107,8 @@ func toPeerConfig(peer *nbpeer.Peer, network *types.Network, dnsName string, set RoutingPeerDnsResolutionEnabled: settings.RoutingPeerDNSResolutionEnabled, LazyConnectionEnabled: settings.LazyConnectionEnabled, AutoUpdate: &proto.AutoUpdateSettings{ - Version: settings.AutoUpdateVersion, + Version: settings.AutoUpdateVersion, + AlwaysUpdate: settings.AutoUpdateAlways, }, } } diff --git a/management/internals/shared/grpc/expose_service.go b/management/internals/shared/grpc/expose_service.go new file mode 100644 index 000000000..1b87f7ede --- /dev/null +++ b/management/internals/shared/grpc/expose_service.go @@ -0,0 +1,251 @@ +package grpc + +import ( + "context" + "fmt" + + pb "github.com/golang/protobuf/proto" // nolint + log "github.com/sirupsen/logrus" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/encryption" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + nbContext "github.com/netbirdio/netbird/management/server/context" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/shared/management/proto" + internalStatus "github.com/netbirdio/netbird/shared/management/status" +) + +// CreateExpose handles a peer request to create a new expose service. +func (s *Server) CreateExpose(ctx context.Context, req *proto.EncryptedMessage) (*proto.EncryptedMessage, error) { + exposeReq := &proto.ExposeServiceRequest{} + peerKey, err := s.parseRequest(ctx, req, exposeReq) + if err != nil { + return nil, err + } + + accountID, peer, err := s.authenticateExposePeer(ctx, peerKey) + if err != nil { + return nil, err + } + + // nolint:staticcheck + ctx = context.WithValue(ctx, nbContext.AccountIDKey, accountID) + + reverseProxyMgr := s.getReverseProxyManager() + if reverseProxyMgr == nil { + return nil, status.Errorf(codes.Internal, "reverse proxy manager not available") + } + + if exposeReq.Port > 65535 { + return nil, status.Errorf(codes.InvalidArgument, "port out of range: %d", exposeReq.Port) + } + if exposeReq.ListenPort > 65535 { + return nil, status.Errorf(codes.InvalidArgument, "listen_port out of range: %d", exposeReq.ListenPort) + } + + mode, err := exposeProtocolToString(exposeReq.Protocol) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "%v", err) + } + + created, err := reverseProxyMgr.CreateServiceFromPeer(ctx, accountID, peer.ID, &rpservice.ExposeServiceRequest{ + NamePrefix: exposeReq.NamePrefix, + Port: uint16(exposeReq.Port), //nolint:gosec // validated above + Mode: mode, + TargetProtocol: exposeTargetProtocol(exposeReq.Protocol), + Domain: exposeReq.Domain, + Pin: exposeReq.Pin, + Password: exposeReq.Password, + UserGroups: exposeReq.UserGroups, + ListenPort: uint16(exposeReq.ListenPort), //nolint:gosec // validated above + }) + if err != nil { + return nil, mapExposeError(ctx, err) + } + + return s.encryptResponse(peerKey, &proto.ExposeServiceResponse{ + ServiceName: created.ServiceName, + ServiceUrl: created.ServiceURL, + Domain: created.Domain, + PortAutoAssigned: created.PortAutoAssigned, + }) +} + +// RenewExpose extends the TTL of an active expose session. +func (s *Server) RenewExpose(ctx context.Context, req *proto.EncryptedMessage) (*proto.EncryptedMessage, error) { + renewReq := &proto.RenewExposeRequest{} + peerKey, err := s.parseRequest(ctx, req, renewReq) + if err != nil { + return nil, err + } + + accountID, peer, err := s.authenticateExposePeer(ctx, peerKey) + if err != nil { + return nil, err + } + + reverseProxyMgr := s.getReverseProxyManager() + if reverseProxyMgr == nil { + return nil, status.Errorf(codes.Internal, "reverse proxy manager not available") + } + + serviceID, err := s.resolveServiceID(ctx, renewReq.Domain) + if err != nil { + return nil, mapExposeError(ctx, err) + } + + if err := reverseProxyMgr.RenewServiceFromPeer(ctx, accountID, peer.ID, serviceID); err != nil { + return nil, mapExposeError(ctx, err) + } + + return s.encryptResponse(peerKey, &proto.RenewExposeResponse{}) +} + +// StopExpose terminates an active expose session. +func (s *Server) StopExpose(ctx context.Context, req *proto.EncryptedMessage) (*proto.EncryptedMessage, error) { + stopReq := &proto.StopExposeRequest{} + peerKey, err := s.parseRequest(ctx, req, stopReq) + if err != nil { + return nil, err + } + + accountID, peer, err := s.authenticateExposePeer(ctx, peerKey) + if err != nil { + return nil, err + } + + reverseProxyMgr := s.getReverseProxyManager() + if reverseProxyMgr == nil { + return nil, status.Errorf(codes.Internal, "reverse proxy manager not available") + } + + serviceID, err := s.resolveServiceID(ctx, stopReq.Domain) + if err != nil { + return nil, mapExposeError(ctx, err) + } + + if err := reverseProxyMgr.StopServiceFromPeer(ctx, accountID, peer.ID, serviceID); err != nil { + return nil, mapExposeError(ctx, err) + } + + return s.encryptResponse(peerKey, &proto.StopExposeResponse{}) +} + +func mapExposeError(ctx context.Context, err error) error { + s, ok := internalStatus.FromError(err) + if !ok { + log.WithContext(ctx).Errorf("expose service error: %v", err) + return status.Errorf(codes.Internal, "internal error") + } + + switch s.Type() { + case internalStatus.InvalidArgument: + return status.Errorf(codes.InvalidArgument, "%s", s.Message) + case internalStatus.PermissionDenied: + return status.Errorf(codes.PermissionDenied, "%s", s.Message) + case internalStatus.NotFound: + return status.Errorf(codes.NotFound, "%s", s.Message) + case internalStatus.AlreadyExists: + return status.Errorf(codes.AlreadyExists, "%s", s.Message) + case internalStatus.PreconditionFailed: + return status.Errorf(codes.ResourceExhausted, "%s", s.Message) + default: + log.WithContext(ctx).Errorf("expose service error: %v", err) + return status.Errorf(codes.Internal, "internal error") + } +} + +func (s *Server) encryptResponse(peerKey wgtypes.Key, msg pb.Message) (*proto.EncryptedMessage, error) { + wgKey, err := s.secretsManager.GetWGKey() + if err != nil { + return nil, status.Errorf(codes.Internal, "internal error") + } + + encryptedResp, err := encryption.EncryptMessage(peerKey, wgKey, msg) + if err != nil { + return nil, status.Errorf(codes.Internal, "encrypt response") + } + + return &proto.EncryptedMessage{ + WgPubKey: wgKey.PublicKey().String(), + Body: encryptedResp, + }, nil +} + +func (s *Server) authenticateExposePeer(ctx context.Context, peerKey wgtypes.Key) (string, *nbpeer.Peer, error) { + accountID, err := s.accountManager.GetAccountIDForPeerKey(ctx, peerKey.String()) + if err != nil { + if errStatus, ok := internalStatus.FromError(err); ok && errStatus.Type() == internalStatus.NotFound { + return "", nil, status.Errorf(codes.PermissionDenied, "peer is not registered") + } + return "", nil, status.Errorf(codes.Internal, "lookup account for peer") + } + + peer, err := s.accountManager.GetStore().GetPeerByPeerPubKey(ctx, store.LockingStrengthNone, peerKey.String()) + if err != nil { + return "", nil, status.Errorf(codes.PermissionDenied, "peer is not registered") + } + + return accountID, peer, nil +} + +func (s *Server) getReverseProxyManager() rpservice.Manager { + s.reverseProxyMu.RLock() + defer s.reverseProxyMu.RUnlock() + return s.reverseProxyManager +} + +// SetReverseProxyManager sets the reverse proxy manager on the server. +func (s *Server) SetReverseProxyManager(mgr rpservice.Manager) { + s.reverseProxyMu.Lock() + defer s.reverseProxyMu.Unlock() + s.reverseProxyManager = mgr +} + +// resolveServiceID looks up the service by its globally unique domain. +func (s *Server) resolveServiceID(ctx context.Context, domain string) (string, error) { + if domain == "" { + return "", status.Errorf(codes.InvalidArgument, "domain is required") + } + + svc, err := s.accountManager.GetStore().GetServiceByDomain(ctx, domain) + if err != nil { + return "", err + } + return svc.ID, nil +} + +func exposeProtocolToString(p proto.ExposeProtocol) (string, error) { + switch p { + case proto.ExposeProtocol_EXPOSE_HTTP, proto.ExposeProtocol_EXPOSE_HTTPS: + return "http", nil + case proto.ExposeProtocol_EXPOSE_TCP: + return "tcp", nil + case proto.ExposeProtocol_EXPOSE_UDP: + return "udp", nil + case proto.ExposeProtocol_EXPOSE_TLS: + return "tls", nil + default: + return "", fmt.Errorf("unsupported expose protocol: %v", p) + } +} + +// exposeTargetProtocol returns the target protocol for the given expose protocol. +// For HTTP mode, this is http or https (the scheme used to connect to the backend). +// For L4 modes, this is tcp or udp (the transport used to connect to the backend). +func exposeTargetProtocol(p proto.ExposeProtocol) string { + switch p { + case proto.ExposeProtocol_EXPOSE_HTTPS: + return rpservice.TargetProtoHTTPS + case proto.ExposeProtocol_EXPOSE_TCP, proto.ExposeProtocol_EXPOSE_TLS: + return rpservice.TargetProtoTCP + case proto.ExposeProtocol_EXPOSE_UDP: + return rpservice.TargetProtoUDP + default: + return rpservice.TargetProtoHTTP + } +} diff --git a/management/internals/shared/grpc/onetime_token.go b/management/internals/shared/grpc/onetime_token.go new file mode 100644 index 000000000..7999407db --- /dev/null +++ b/management/internals/shared/grpc/onetime_token.go @@ -0,0 +1,136 @@ +package grpc + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "time" + + "github.com/eko/gocache/lib/v4/cache" + "github.com/eko/gocache/lib/v4/store" + log "github.com/sirupsen/logrus" + + nbcache "github.com/netbirdio/netbird/management/server/cache" +) + +type tokenMetadata struct { + ServiceID string + AccountID string + ExpiresAt time.Time + CreatedAt time.Time +} + +// OneTimeTokenStore manages single-use authentication tokens for proxy-to-management RPC. +// Supports both in-memory and Redis storage via NB_IDP_CACHE_REDIS_ADDRESS env var. +type OneTimeTokenStore struct { + cache *cache.Cache[string] + ctx context.Context +} + +// NewOneTimeTokenStore creates a token store with automatic backend selection +func NewOneTimeTokenStore(ctx context.Context, maxTimeout, cleanupInterval time.Duration, maxConn int) (*OneTimeTokenStore, error) { + cacheStore, err := nbcache.NewStore(ctx, maxTimeout, cleanupInterval, maxConn) + if err != nil { + return nil, fmt.Errorf("failed to create cache store: %w", err) + } + + return &OneTimeTokenStore{ + cache: cache.New[string](cacheStore), + ctx: ctx, + }, nil +} + +// GenerateToken creates a new cryptographically secure one-time token +// with the specified TTL. The token is associated with a specific +// accountID and serviceID for validation purposes. +// +// Returns the generated token string or an error if random generation fails. +func (s *OneTimeTokenStore) GenerateToken(accountID, serviceID string, ttl time.Duration) (string, error) { + randomBytes := make([]byte, 32) + if _, err := rand.Read(randomBytes); err != nil { + return "", fmt.Errorf("failed to generate random token: %w", err) + } + + token := base64.URLEncoding.EncodeToString(randomBytes) + hashedToken := hashToken(token) + + metadata := &tokenMetadata{ + ServiceID: serviceID, + AccountID: accountID, + ExpiresAt: time.Now().Add(ttl), + CreatedAt: time.Now(), + } + + metadataJSON, err := json.Marshal(metadata) + if err != nil { + return "", fmt.Errorf("failed to serialize token metadata: %w", err) + } + + if err := s.cache.Set(s.ctx, hashedToken, string(metadataJSON), store.WithExpiration(ttl)); err != nil { + return "", fmt.Errorf("failed to store token: %w", err) + } + + log.Debugf("Generated one-time token for proxy %s in account %s (expires in %s)", + serviceID, accountID, ttl) + + return token, nil +} + +// ValidateAndConsume verifies the token against the provided accountID and +// serviceID, checks expiration, and then deletes it to enforce single-use. +// +// This method uses constant-time comparison to prevent timing attacks. +// +// Returns nil on success, or an error if: +// - Token doesn't exist +// - Token has expired +// - Account ID doesn't match +// - Reverse proxy ID doesn't match +func (s *OneTimeTokenStore) ValidateAndConsume(token, accountID, serviceID string) error { + hashedToken := hashToken(token) + + metadataJSON, err := s.cache.Get(s.ctx, hashedToken) + if err != nil { + log.Warnf("Token validation failed: token not found (proxy: %s, account: %s)", serviceID, accountID) + return fmt.Errorf("invalid token") + } + + metadata := &tokenMetadata{} + if err := json.Unmarshal([]byte(metadataJSON), metadata); err != nil { + log.Warnf("Token validation failed: failed to unmarshal metadata (proxy: %s, account: %s): %v", serviceID, accountID, err) + return fmt.Errorf("invalid token metadata") + } + + if time.Now().After(metadata.ExpiresAt) { + log.Warnf("Token validation failed: token expired (proxy: %s, account: %s)", serviceID, accountID) + return fmt.Errorf("token expired") + } + + if subtle.ConstantTimeCompare([]byte(metadata.AccountID), []byte(accountID)) != 1 { + log.Warnf("Token validation failed: account ID mismatch (expected: %s, got: %s)", metadata.AccountID, accountID) + return fmt.Errorf("account ID mismatch") + } + + if subtle.ConstantTimeCompare([]byte(metadata.ServiceID), []byte(serviceID)) != 1 { + log.Warnf("Token validation failed: service ID mismatch (expected: %s, got: %s)", metadata.ServiceID, serviceID) + return fmt.Errorf("service ID mismatch") + } + + if err := s.cache.Delete(s.ctx, hashedToken); err != nil { + log.Warnf("Token deletion warning (proxy: %s, account: %s): %v", serviceID, accountID, err) + } + + log.Infof("Token validated and consumed for proxy %s in account %s", serviceID, accountID) + + return nil +} + +func hashToken(token string) string { + hash := sha256.Sum256([]byte(token)) + return hex.EncodeToString(hash[:]) +} diff --git a/management/internals/shared/grpc/pkce_verifier.go b/management/internals/shared/grpc/pkce_verifier.go new file mode 100644 index 000000000..441e8b051 --- /dev/null +++ b/management/internals/shared/grpc/pkce_verifier.go @@ -0,0 +1,61 @@ +package grpc + +import ( + "context" + "fmt" + "time" + + "github.com/eko/gocache/lib/v4/cache" + "github.com/eko/gocache/lib/v4/store" + log "github.com/sirupsen/logrus" + + nbcache "github.com/netbirdio/netbird/management/server/cache" +) + +// PKCEVerifierStore manages PKCE verifiers for OAuth flows. +// Supports both in-memory and Redis storage via NB_IDP_CACHE_REDIS_ADDRESS env var. +type PKCEVerifierStore struct { + cache *cache.Cache[string] + ctx context.Context +} + +// NewPKCEVerifierStore creates a PKCE verifier store with automatic backend selection +func NewPKCEVerifierStore(ctx context.Context, maxTimeout, cleanupInterval time.Duration, maxConn int) (*PKCEVerifierStore, error) { + cacheStore, err := nbcache.NewStore(ctx, maxTimeout, cleanupInterval, maxConn) + if err != nil { + return nil, fmt.Errorf("failed to create cache store: %w", err) + } + + return &PKCEVerifierStore{ + cache: cache.New[string](cacheStore), + ctx: ctx, + }, nil +} + +// Store saves a PKCE verifier associated with an OAuth state parameter. +// The verifier is stored with the specified TTL and will be automatically deleted after expiration. +func (s *PKCEVerifierStore) Store(state, verifier string, ttl time.Duration) error { + if err := s.cache.Set(s.ctx, state, verifier, store.WithExpiration(ttl)); err != nil { + return fmt.Errorf("failed to store PKCE verifier: %w", err) + } + + log.Debugf("Stored PKCE verifier for state (expires in %s)", ttl) + return nil +} + +// LoadAndDelete retrieves and removes a PKCE verifier for the given state. +// Returns the verifier and true if found, or empty string and false if not found. +// This enforces single-use semantics for PKCE verifiers. +func (s *PKCEVerifierStore) LoadAndDelete(state string) (string, bool) { + verifier, err := s.cache.Get(s.ctx, state) + if err != nil { + log.Debugf("PKCE verifier not found for state") + return "", false + } + + if err := s.cache.Delete(s.ctx, state); err != nil { + log.Warnf("Failed to delete PKCE verifier for state: %v", err) + } + + return verifier, true +} diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go new file mode 100644 index 000000000..fd993fb40 --- /dev/null +++ b/management/internals/shared/grpc/proxy.go @@ -0,0 +1,1145 @@ +package grpc + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + log "github.com/sirupsen/logrus" + "golang.org/x/oauth2" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/shared/management/domain" + + "github.com/netbirdio/netbird/management/internals/modules/peers" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/management/server/users" + proxyauth "github.com/netbirdio/netbird/proxy/auth" + "github.com/netbirdio/netbird/shared/hash/argon2id" + "github.com/netbirdio/netbird/shared/management/proto" + nbstatus "github.com/netbirdio/netbird/shared/management/status" +) + +type ProxyOIDCConfig struct { + Issuer string + ClientID string + Scopes []string + CallbackURL string + HMACKey []byte + + Audience string + KeysLocation string +} + +// ProxyServiceServer implements the ProxyService gRPC server +type ProxyServiceServer struct { + proto.UnimplementedProxyServiceServer + + // Map of connected proxies: proxy_id -> proxy connection + connectedProxies sync.Map + + // Manager for access logs + accessLogManager accesslogs.Manager + + mu sync.RWMutex + // Manager for reverse proxy operations + serviceManager rpservice.Manager + // ProxyController for service updates and cluster management + proxyController proxy.Controller + + // Manager for proxy connections + proxyManager proxy.Manager + + // Manager for peers + peersManager peers.Manager + + // Manager for users + usersManager users.Manager + + // Store for one-time authentication tokens + tokenStore *OneTimeTokenStore + + // OIDC configuration for proxy authentication + oidcConfig ProxyOIDCConfig + + // Store for PKCE verifiers + pkceVerifierStore *PKCEVerifierStore + + cancel context.CancelFunc +} + +const pkceVerifierTTL = 10 * time.Minute + +// proxyConnection represents a connected proxy +type proxyConnection struct { + proxyID string + address string + capabilities *proto.ProxyCapabilities + stream proto.ProxyService_GetMappingUpdateServer + sendChan chan *proto.GetMappingUpdateResponse + ctx context.Context + cancel context.CancelFunc +} + +// NewProxyServiceServer creates a new proxy service server. +func NewProxyServiceServer(accessLogMgr accesslogs.Manager, tokenStore *OneTimeTokenStore, pkceStore *PKCEVerifierStore, oidcConfig ProxyOIDCConfig, peersManager peers.Manager, usersManager users.Manager, proxyMgr proxy.Manager) *ProxyServiceServer { + ctx, cancel := context.WithCancel(context.Background()) + s := &ProxyServiceServer{ + accessLogManager: accessLogMgr, + oidcConfig: oidcConfig, + tokenStore: tokenStore, + pkceVerifierStore: pkceStore, + peersManager: peersManager, + usersManager: usersManager, + proxyManager: proxyMgr, + cancel: cancel, + } + go s.cleanupStaleProxies(ctx) + return s +} + +// cleanupStaleProxies periodically removes proxies that haven't sent heartbeat in 10 minutes +func (s *ProxyServiceServer) cleanupStaleProxies(ctx context.Context) { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := s.proxyManager.CleanupStale(ctx, 10*time.Minute); err != nil { + log.WithContext(ctx).Debugf("Failed to cleanup stale proxies: %v", err) + } + } + } +} + +// Close stops background goroutines. +func (s *ProxyServiceServer) Close() { + s.cancel() +} + +// SetServiceManager sets the service manager. Must be called before serving. +func (s *ProxyServiceServer) SetServiceManager(manager rpservice.Manager) { + s.mu.Lock() + defer s.mu.Unlock() + s.serviceManager = manager +} + +// SetProxyController sets the proxy controller. Must be called before serving. +func (s *ProxyServiceServer) SetProxyController(proxyController proxy.Controller) { + s.mu.Lock() + defer s.mu.Unlock() + s.proxyController = proxyController +} + +// GetMappingUpdate handles the control stream with proxy clients +func (s *ProxyServiceServer) GetMappingUpdate(req *proto.GetMappingUpdateRequest, stream proto.ProxyService_GetMappingUpdateServer) error { + ctx := stream.Context() + + peerInfo := PeerIPFromContext(ctx) + log.Infof("New proxy connection from %s", peerInfo) + + proxyID := req.GetProxyId() + if proxyID == "" { + return status.Errorf(codes.InvalidArgument, "proxy_id is required") + } + + proxyAddress := req.GetAddress() + if !isProxyAddressValid(proxyAddress) { + return status.Errorf(codes.InvalidArgument, "proxy address is invalid") + } + + connCtx, cancel := context.WithCancel(ctx) + conn := &proxyConnection{ + proxyID: proxyID, + address: proxyAddress, + capabilities: req.GetCapabilities(), + stream: stream, + sendChan: make(chan *proto.GetMappingUpdateResponse, 100), + ctx: connCtx, + cancel: cancel, + } + + s.connectedProxies.Store(proxyID, conn) + if err := s.proxyController.RegisterProxyToCluster(ctx, conn.address, proxyID); err != nil { + log.WithContext(ctx).Warnf("Failed to register proxy %s in cluster: %v", proxyID, err) + } + + // Register proxy in database + if err := s.proxyManager.Connect(ctx, proxyID, proxyAddress, peerInfo); err != nil { + log.WithContext(ctx).Warnf("Failed to register proxy %s in database: %v", proxyID, err) + } + + log.WithFields(log.Fields{ + "proxy_id": proxyID, + "address": proxyAddress, + "cluster_addr": proxyAddress, + "total_proxies": len(s.GetConnectedProxies()), + }).Info("Proxy registered in cluster") + defer func() { + if err := s.proxyManager.Disconnect(context.Background(), proxyID); err != nil { + log.Warnf("Failed to mark proxy %s as disconnected: %v", proxyID, err) + } + + s.connectedProxies.Delete(proxyID) + if err := s.proxyController.UnregisterProxyFromCluster(context.Background(), conn.address, proxyID); err != nil { + log.Warnf("Failed to unregister proxy %s from cluster: %v", proxyID, err) + } + + cancel() + log.Infof("Proxy %s disconnected", proxyID) + }() + + if err := s.sendSnapshot(ctx, conn); err != nil { + return fmt.Errorf("send snapshot to proxy %s: %w", proxyID, err) + } + + errChan := make(chan error, 2) + go s.sender(conn, errChan) + + // Start heartbeat goroutine + go s.heartbeat(connCtx, proxyID) + + select { + case err := <-errChan: + return fmt.Errorf("send update to proxy %s: %w", proxyID, err) + case <-connCtx.Done(): + return connCtx.Err() + } +} + +// heartbeat updates the proxy's last_seen timestamp every minute +func (s *ProxyServiceServer) heartbeat(ctx context.Context, proxyID string) { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if err := s.proxyManager.Heartbeat(ctx, proxyID); err != nil { + log.WithContext(ctx).Debugf("Failed to update proxy %s heartbeat: %v", proxyID, err) + } + case <-ctx.Done(): + return + } + } +} + +// sendSnapshot sends the initial snapshot of services to the connecting proxy. +// Only entries matching the proxy's cluster address are sent. +func (s *ProxyServiceServer) sendSnapshot(ctx context.Context, conn *proxyConnection) error { + if !isProxyAddressValid(conn.address) { + return fmt.Errorf("proxy address is invalid") + } + + mappings, err := s.snapshotServiceMappings(ctx, conn) + if err != nil { + return err + } + + if len(mappings) == 0 { + if err := conn.stream.Send(&proto.GetMappingUpdateResponse{ + InitialSyncComplete: true, + }); err != nil { + return fmt.Errorf("send snapshot completion: %w", err) + } + return nil + } + + for i, m := range mappings { + if err := conn.stream.Send(&proto.GetMappingUpdateResponse{ + Mapping: []*proto.ProxyMapping{m}, + InitialSyncComplete: i == len(mappings)-1, + }); err != nil { + return fmt.Errorf("send proxy mapping: %w", err) + } + } + + return nil +} + +func (s *ProxyServiceServer) snapshotServiceMappings(ctx context.Context, conn *proxyConnection) ([]*proto.ProxyMapping, error) { + services, err := s.serviceManager.GetGlobalServices(ctx) + if err != nil { + return nil, fmt.Errorf("get services from store: %w", err) + } + + var mappings []*proto.ProxyMapping + for _, service := range services { + if !service.Enabled || service.ProxyCluster == "" || service.ProxyCluster != conn.address { + continue + } + + token, err := s.tokenStore.GenerateToken(service.AccountID, service.ID, 5*time.Minute) + if err != nil { + log.WithFields(log.Fields{ + "service": service.Name, + "account": service.AccountID, + }).WithError(err).Error("failed to generate auth token for snapshot") + continue + } + + m := service.ToProtoMapping(rpservice.Create, token, s.GetOIDCValidationConfig()) + mappings = append(mappings, m) + } + return mappings, nil +} + +// isProxyAddressValid validates a proxy address +func isProxyAddressValid(addr string) bool { + _, err := domain.ValidateDomains([]string{addr}) + return err == nil +} + +// sender handles sending messages to proxy +func (s *ProxyServiceServer) sender(conn *proxyConnection, errChan chan<- error) { + for { + select { + case resp := <-conn.sendChan: + if err := conn.stream.Send(resp); err != nil { + errChan <- err + return + } + case <-conn.ctx.Done(): + return + } + } +} + +// SendAccessLog processes access log from proxy +func (s *ProxyServiceServer) SendAccessLog(ctx context.Context, req *proto.SendAccessLogRequest) (*proto.SendAccessLogResponse, error) { + accessLog := req.GetLog() + + fields := log.Fields{ + "service_id": accessLog.GetServiceId(), + "account_id": accessLog.GetAccountId(), + "host": accessLog.GetHost(), + "source_ip": accessLog.GetSourceIp(), + } + if mechanism := accessLog.GetAuthMechanism(); mechanism != "" { + fields["auth_mechanism"] = mechanism + } + if userID := accessLog.GetUserId(); userID != "" { + fields["user_id"] = userID + } + if !accessLog.GetAuthSuccess() { + fields["auth_success"] = false + } + log.WithFields(fields).Debugf("%s %s %d (%dms)", + accessLog.GetMethod(), + accessLog.GetPath(), + accessLog.GetResponseCode(), + accessLog.GetDurationMs(), + ) + + logEntry := &accesslogs.AccessLogEntry{} + logEntry.FromProto(accessLog) + + if err := s.accessLogManager.SaveAccessLog(ctx, logEntry); err != nil { + log.WithContext(ctx).Errorf("failed to save access log: %v", err) + return nil, status.Errorf(codes.Internal, "save access log: %v", err) + } + + return &proto.SendAccessLogResponse{}, nil +} + +// SendServiceUpdate broadcasts a service update to all connected proxy servers. +// Management should call this when services are created/updated/removed. +// For create/update operations a unique one-time auth token is generated per +// proxy so that every replica can independently authenticate with management. +func (s *ProxyServiceServer) SendServiceUpdate(update *proto.GetMappingUpdateResponse) { + log.Debugf("Broadcasting service update to all connected proxy servers") + s.connectedProxies.Range(func(key, value interface{}) bool { + conn := value.(*proxyConnection) + resp := s.perProxyMessage(update, conn.proxyID) + if resp == nil { + return true + } + select { + case conn.sendChan <- resp: + log.Debugf("Sent service update to proxy server %s", conn.proxyID) + default: + log.Warnf("Failed to send service update to proxy server %s (channel full)", conn.proxyID) + } + return true + }) +} + +// GetConnectedProxies returns a list of connected proxy IDs +func (s *ProxyServiceServer) GetConnectedProxies() []string { + var proxies []string + s.connectedProxies.Range(func(key, value interface{}) bool { + proxies = append(proxies, key.(string)) + return true + }) + return proxies +} + +// GetConnectedProxyURLs returns a deduplicated list of URLs from all connected proxies. +func (s *ProxyServiceServer) GetConnectedProxyURLs() []string { + seenUrls := make(map[string]struct{}) + var urls []string + var proxyCount int + s.connectedProxies.Range(func(key, value interface{}) bool { + proxyCount++ + conn := value.(*proxyConnection) + log.WithFields(log.Fields{ + "proxy_id": conn.proxyID, + "address": conn.address, + }).Debug("checking connected proxy for URL") + if _, seen := seenUrls[conn.address]; conn.address != "" && !seen { + seenUrls[conn.address] = struct{}{} + urls = append(urls, conn.address) + } + return true + }) + log.WithFields(log.Fields{ + "total_proxies": proxyCount, + "unique_urls": len(urls), + "connected_urls": urls, + }).Debug("GetConnectedProxyURLs result") + return urls +} + +// SendServiceUpdateToCluster sends a service update to all proxy servers in a specific cluster. +// If clusterAddr is empty, broadcasts to all connected proxy servers (backward compatibility). +// For create/update operations a unique one-time auth token is generated per +// proxy so that every replica can independently authenticate with management. +func (s *ProxyServiceServer) SendServiceUpdateToCluster(ctx context.Context, update *proto.ProxyMapping, clusterAddr string) { + updateResponse := &proto.GetMappingUpdateResponse{ + Mapping: []*proto.ProxyMapping{update}, + } + + if clusterAddr == "" { + s.SendServiceUpdate(updateResponse) + return + } + + if s.proxyController == nil { + log.WithContext(ctx).Debugf("ProxyController not set, cannot send to cluster %s", clusterAddr) + return + } + + proxyIDs := s.proxyController.GetProxiesForCluster(clusterAddr) + if len(proxyIDs) == 0 { + log.WithContext(ctx).Debugf("No proxies connected for cluster %s", clusterAddr) + return + } + + log.Debugf("Sending service update to cluster %s", clusterAddr) + for _, proxyID := range proxyIDs { + if connVal, ok := s.connectedProxies.Load(proxyID); ok { + conn := connVal.(*proxyConnection) + msg := s.perProxyMessage(updateResponse, proxyID) + if msg == nil { + continue + } + select { + case conn.sendChan <- msg: + log.WithContext(ctx).Debugf("Sent service update with id %s to proxy %s in cluster %s", update.Id, proxyID, clusterAddr) + default: + log.WithContext(ctx).Warnf("Failed to send service update to proxy %s in cluster %s (channel full)", proxyID, clusterAddr) + } + } + } +} + +// perProxyMessage returns a copy of update with a fresh one-time token for +// create/update operations. For delete operations the original mapping is +// used unchanged because proxies do not need to authenticate for removal. +// Returns nil if token generation fails (the proxy should be skipped). +func (s *ProxyServiceServer) perProxyMessage(update *proto.GetMappingUpdateResponse, proxyID string) *proto.GetMappingUpdateResponse { + resp := make([]*proto.ProxyMapping, 0, len(update.Mapping)) + for _, mapping := range update.Mapping { + if mapping.Type == proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED { + resp = append(resp, mapping) + continue + } + + token, err := s.tokenStore.GenerateToken(mapping.AccountId, mapping.Id, 5*time.Minute) + if err != nil { + log.Warnf("Failed to generate token for proxy %s: %v", proxyID, err) + return nil + } + + msg := shallowCloneMapping(mapping) + msg.AuthToken = token + resp = append(resp, msg) + } + + return &proto.GetMappingUpdateResponse{ + Mapping: resp, + } +} + +// shallowCloneMapping creates a shallow copy of a ProxyMapping, reusing the +// same slice/pointer fields. Only scalar fields that differ per proxy (AuthToken) +// should be set on the copy. +func shallowCloneMapping(m *proto.ProxyMapping) *proto.ProxyMapping { + return &proto.ProxyMapping{ + Type: m.Type, + Id: m.Id, + AccountId: m.AccountId, + Domain: m.Domain, + Path: m.Path, + Auth: m.Auth, + PassHostHeader: m.PassHostHeader, + RewriteRedirects: m.RewriteRedirects, + Mode: m.Mode, + ListenPort: m.ListenPort, + AccessRestrictions: m.AccessRestrictions, + } +} + +// ClusterSupportsCustomPorts returns whether any connected proxy in the given +// cluster reports custom port support. Returns nil if no proxy has reported +// capabilities (old proxies that predate the field). +func (s *ProxyServiceServer) ClusterSupportsCustomPorts(clusterAddr string) *bool { + if s.proxyController == nil { + return nil + } + + var hasCapabilities bool + for _, pid := range s.proxyController.GetProxiesForCluster(clusterAddr) { + connVal, ok := s.connectedProxies.Load(pid) + if !ok { + continue + } + conn := connVal.(*proxyConnection) + if conn.capabilities == nil || conn.capabilities.SupportsCustomPorts == nil { + continue + } + if *conn.capabilities.SupportsCustomPorts { + return ptr(true) + } + hasCapabilities = true + } + if hasCapabilities { + return ptr(false) + } + return nil +} + +func (s *ProxyServiceServer) Authenticate(ctx context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) { + service, err := s.serviceManager.GetServiceByID(ctx, req.GetAccountId(), req.GetId()) + if err != nil { + log.WithContext(ctx).Debugf("failed to get service from store: %v", err) + return nil, status.Errorf(codes.FailedPrecondition, "get service from store: %v", err) + } + + authenticated, userId, method := s.authenticateRequest(ctx, req, service) + + token, err := s.generateSessionToken(ctx, authenticated, service, userId, method) + if err != nil { + return nil, err + } + + return &proto.AuthenticateResponse{ + Success: authenticated, + SessionToken: token, + }, nil +} + +func (s *ProxyServiceServer) authenticateRequest(ctx context.Context, req *proto.AuthenticateRequest, service *rpservice.Service) (bool, string, proxyauth.Method) { + switch v := req.GetRequest().(type) { + case *proto.AuthenticateRequest_Pin: + return s.authenticatePIN(ctx, req.GetId(), v, service.Auth.PinAuth) + case *proto.AuthenticateRequest_Password: + return s.authenticatePassword(ctx, req.GetId(), v, service.Auth.PasswordAuth) + case *proto.AuthenticateRequest_HeaderAuth: + return s.authenticateHeader(ctx, req.GetId(), v, service.Auth.HeaderAuths) + default: + return false, "", "" + } +} + +func (s *ProxyServiceServer) authenticatePIN(ctx context.Context, serviceID string, req *proto.AuthenticateRequest_Pin, auth *rpservice.PINAuthConfig) (bool, string, proxyauth.Method) { + if auth == nil || !auth.Enabled { + log.WithContext(ctx).Debugf("PIN authentication attempted but not enabled for service %s", serviceID) + return false, "", "" + } + + if err := argon2id.Verify(req.Pin.GetPin(), auth.Pin); err != nil { + s.logAuthenticationError(ctx, err, "PIN") + return false, "", "" + } + + return true, "pin-user", proxyauth.MethodPIN +} + +func (s *ProxyServiceServer) authenticatePassword(ctx context.Context, serviceID string, req *proto.AuthenticateRequest_Password, auth *rpservice.PasswordAuthConfig) (bool, string, proxyauth.Method) { + if auth == nil || !auth.Enabled { + log.WithContext(ctx).Debugf("password authentication attempted but not enabled for service %s", serviceID) + return false, "", "" + } + + if err := argon2id.Verify(req.Password.GetPassword(), auth.Password); err != nil { + s.logAuthenticationError(ctx, err, "Password") + return false, "", "" + } + + return true, "password-user", proxyauth.MethodPassword +} + +func (s *ProxyServiceServer) authenticateHeader(ctx context.Context, serviceID string, req *proto.AuthenticateRequest_HeaderAuth, auths []*rpservice.HeaderAuthConfig) (bool, string, proxyauth.Method) { + if len(auths) == 0 { + log.WithContext(ctx).Debugf("header authentication attempted but no header auths configured for service %s", serviceID) + return false, "", "" + } + + headerName := http.CanonicalHeaderKey(req.HeaderAuth.GetHeaderName()) + + var lastErr error + for _, auth := range auths { + if auth == nil || !auth.Enabled { + continue + } + if headerName != "" && http.CanonicalHeaderKey(auth.Header) != headerName { + continue + } + if err := argon2id.Verify(req.HeaderAuth.GetHeaderValue(), auth.Value); err != nil { + lastErr = err + continue + } + return true, "header-user", proxyauth.MethodHeader + } + + if lastErr != nil { + s.logAuthenticationError(ctx, lastErr, "Header") + } + return false, "", "" +} + +func (s *ProxyServiceServer) logAuthenticationError(ctx context.Context, err error, authType string) { + if errors.Is(err, argon2id.ErrMismatchedHashAndPassword) { + log.WithContext(ctx).Tracef("%s authentication failed: invalid credentials", authType) + } else { + log.WithContext(ctx).Errorf("%s authentication error: %v", authType, err) + } +} + +func (s *ProxyServiceServer) generateSessionToken(ctx context.Context, authenticated bool, service *rpservice.Service, userId string, method proxyauth.Method) (string, error) { + if !authenticated || service.SessionPrivateKey == "" { + return "", nil + } + + token, err := sessionkey.SignToken( + service.SessionPrivateKey, + userId, + service.Domain, + method, + proxyauth.DefaultSessionExpiry, + ) + if err != nil { + log.WithContext(ctx).WithError(err).Error("failed to sign session token") + return "", status.Errorf(codes.Internal, "sign session token: %v", err) + } + + return token, nil +} + +// SendStatusUpdate handles status updates from proxy clients. +func (s *ProxyServiceServer) SendStatusUpdate(ctx context.Context, req *proto.SendStatusUpdateRequest) (*proto.SendStatusUpdateResponse, error) { + accountID := req.GetAccountId() + serviceID := req.GetServiceId() + protoStatus := req.GetStatus() + certificateIssued := req.GetCertificateIssued() + + log.WithFields(log.Fields{ + "service_id": serviceID, + "account_id": accountID, + "status": protoStatus, + "certificate_issued": certificateIssued, + "error_message": req.GetErrorMessage(), + }).Debug("Status update from proxy server") + + if serviceID == "" || accountID == "" { + return nil, status.Errorf(codes.InvalidArgument, "service_id and account_id are required") + } + + internalStatus := protoStatusToInternal(protoStatus) + + if err := s.serviceManager.SetStatus(ctx, accountID, serviceID, internalStatus); err != nil { + sErr, isNbErr := nbstatus.FromError(err) + if isNbErr && sErr.Type() == nbstatus.NotFound { + return nil, status.Errorf(codes.NotFound, "service %s not found", serviceID) + } + log.WithContext(ctx).WithError(err).Error("failed to update service status") + return nil, status.Errorf(codes.Internal, "update service status: %v", err) + } + + if certificateIssued { + if err := s.serviceManager.SetCertificateIssuedAt(ctx, accountID, serviceID); err != nil { + log.WithContext(ctx).WithError(err).Error("failed to set certificate issued timestamp") + return nil, status.Errorf(codes.Internal, "update certificate timestamp: %v", err) + } + log.WithFields(log.Fields{ + "service_id": serviceID, + "account_id": accountID, + }).Info("Certificate issued timestamp updated") + } + + log.WithFields(log.Fields{ + "service_id": serviceID, + "account_id": accountID, + "status": internalStatus, + }).Info("Service status updated") + + return &proto.SendStatusUpdateResponse{}, nil +} + +// protoStatusToInternal maps proto status to internal service status. +func protoStatusToInternal(protoStatus proto.ProxyStatus) rpservice.Status { + switch protoStatus { + case proto.ProxyStatus_PROXY_STATUS_PENDING: + return rpservice.StatusPending + case proto.ProxyStatus_PROXY_STATUS_ACTIVE: + return rpservice.StatusActive + case proto.ProxyStatus_PROXY_STATUS_TUNNEL_NOT_CREATED: + return rpservice.StatusTunnelNotCreated + case proto.ProxyStatus_PROXY_STATUS_CERTIFICATE_PENDING: + return rpservice.StatusCertificatePending + case proto.ProxyStatus_PROXY_STATUS_CERTIFICATE_FAILED: + return rpservice.StatusCertificateFailed + case proto.ProxyStatus_PROXY_STATUS_ERROR: + return rpservice.StatusError + default: + return rpservice.StatusError + } +} + +// CreateProxyPeer handles proxy peer creation with one-time token authentication +func (s *ProxyServiceServer) CreateProxyPeer(ctx context.Context, req *proto.CreateProxyPeerRequest) (*proto.CreateProxyPeerResponse, error) { + serviceID := req.GetServiceId() + accountID := req.GetAccountId() + token := req.GetToken() + cluster := req.GetCluster() + key := req.WireguardPublicKey + + log.WithFields(log.Fields{ + "service_id": serviceID, + "account_id": accountID, + "cluster": cluster, + }).Debug("CreateProxyPeer request received") + + if serviceID == "" || accountID == "" || token == "" { + log.Warn("CreateProxyPeer: missing required fields") + return &proto.CreateProxyPeerResponse{ + Success: false, + ErrorMessage: strPtr("missing required fields: service_id, account_id, and token are required"), + }, nil + } + + if err := s.tokenStore.ValidateAndConsume(token, accountID, serviceID); err != nil { + log.WithFields(log.Fields{ + "service_id": serviceID, + "account_id": accountID, + }).WithError(err).Warn("CreateProxyPeer: token validation failed") + return &proto.CreateProxyPeerResponse{ + Success: false, + ErrorMessage: strPtr("authentication failed: invalid or expired token"), + }, status.Errorf(codes.Unauthenticated, "token validation: %v", err) + } + + err := s.peersManager.CreateProxyPeer(ctx, accountID, key, cluster) + if err != nil { + log.WithFields(log.Fields{ + "service_id": serviceID, + "account_id": accountID, + }).WithError(err).Error("failed to create proxy peer") + return &proto.CreateProxyPeerResponse{ + Success: false, + ErrorMessage: strPtr(fmt.Sprintf("create proxy peer: %v", err)), + }, status.Errorf(codes.Internal, "create proxy peer: %v", err) + } + + return &proto.CreateProxyPeerResponse{ + Success: true, + }, nil +} + +// strPtr is a helper to create a string pointer for optional proto fields +func strPtr(s string) *string { + return &s +} + +func (s *ProxyServiceServer) GetOIDCURL(ctx context.Context, req *proto.GetOIDCURLRequest) (*proto.GetOIDCURLResponse, error) { + redirectURL, err := url.Parse(req.GetRedirectUrl()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "parse redirect url: %v", err) + } + if redirectURL.Scheme != "https" && redirectURL.Scheme != "http" { + return nil, status.Errorf(codes.InvalidArgument, "redirect URL must use http or https scheme") + } + // Validate redirectURL against known service endpoints to avoid abuse of OIDC redirection. + services, err := s.serviceManager.GetAccountServices(ctx, req.GetAccountId()) + if err != nil { + log.WithContext(ctx).Errorf("failed to get account services: %v", err) + return nil, status.Errorf(codes.FailedPrecondition, "get account services: %v", err) + } + var found bool + for _, service := range services { + if service.Domain == redirectURL.Hostname() { + found = true + break + } + } + if !found { + log.WithContext(ctx).Debugf("OIDC redirect URL %q does not match any service domain", redirectURL.Hostname()) + return nil, status.Errorf(codes.FailedPrecondition, "service not found in store") + } + + provider, err := oidc.NewProvider(ctx, s.oidcConfig.Issuer) + if err != nil { + log.WithContext(ctx).Errorf("failed to create OIDC provider: %v", err) + return nil, status.Errorf(codes.FailedPrecondition, "create OIDC provider: %v", err) + } + + scopes := s.oidcConfig.Scopes + if len(scopes) == 0 { + scopes = []string{oidc.ScopeOpenID, "profile", "email"} + } + + // Generate a random nonce to ensure each OIDC request gets a unique state. + // Without this, multiple requests to the same URL would generate the same state + // but different PKCE verifiers, causing the later verifier to overwrite the earlier one. + nonce := make([]byte, 16) + if _, err := rand.Read(nonce); err != nil { + return nil, status.Errorf(codes.Internal, "generate nonce: %v", err) + } + nonceB64 := base64.URLEncoding.EncodeToString(nonce) + + // Using an HMAC here to avoid redirection state being modified. + // State format: base64(redirectURL)|nonce|hmac(redirectURL|nonce) + payload := redirectURL.String() + "|" + nonceB64 + hmacSum := s.generateHMAC(payload) + state := fmt.Sprintf("%s|%s|%s", base64.URLEncoding.EncodeToString([]byte(redirectURL.String())), nonceB64, hmacSum) + + codeVerifier := oauth2.GenerateVerifier() + if err := s.pkceVerifierStore.Store(state, codeVerifier, pkceVerifierTTL); err != nil { + log.WithContext(ctx).Errorf("failed to store PKCE verifier: %v", err) + return nil, status.Errorf(codes.Internal, "store PKCE verifier: %v", err) + } + + return &proto.GetOIDCURLResponse{ + Url: (&oauth2.Config{ + ClientID: s.oidcConfig.ClientID, + Endpoint: provider.Endpoint(), + RedirectURL: s.oidcConfig.CallbackURL, + Scopes: scopes, + }).AuthCodeURL(state, oauth2.S256ChallengeOption(codeVerifier)), + }, nil +} + +// GetOIDCConfig returns the OIDC configuration for token validation. +func (s *ProxyServiceServer) GetOIDCConfig() ProxyOIDCConfig { + return s.oidcConfig +} + +// GetOIDCValidationConfig returns the OIDC configuration for token validation +// in the format needed by ToProtoMapping. +func (s *ProxyServiceServer) GetOIDCValidationConfig() proxy.OIDCValidationConfig { + return proxy.OIDCValidationConfig{ + Issuer: s.oidcConfig.Issuer, + Audiences: []string{s.oidcConfig.Audience}, + KeysLocation: s.oidcConfig.KeysLocation, + MaxTokenAgeSeconds: 0, // No max token age by default + } +} + +func (s *ProxyServiceServer) generateHMAC(input string) string { + mac := hmac.New(sha256.New, s.oidcConfig.HMACKey) + mac.Write([]byte(input)) + return hex.EncodeToString(mac.Sum(nil)) +} + +// ValidateState validates the state parameter from an OAuth callback. +// Returns the original redirect URL if valid, or an error if invalid. +// The HMAC is verified before consuming the PKCE verifier to prevent +// an attacker from invalidating a legitimate user's auth flow. +func (s *ProxyServiceServer) ValidateState(state string) (verifier, redirectURL string, err error) { + // State format: base64(redirectURL)|nonce|hmac(redirectURL|nonce) + parts := strings.Split(state, "|") + if len(parts) != 3 { + return "", "", errors.New("invalid state format") + } + + encodedURL := parts[0] + nonce := parts[1] + providedHMAC := parts[2] + + redirectURLBytes, err := base64.URLEncoding.DecodeString(encodedURL) + if err != nil { + return "", "", fmt.Errorf("invalid state encoding: %w", err) + } + redirectURL = string(redirectURLBytes) + + payload := redirectURL + "|" + nonce + expectedHMAC := s.generateHMAC(payload) + + if !hmac.Equal([]byte(providedHMAC), []byte(expectedHMAC)) { + return "", "", errors.New("invalid state signature") + } + + // Consume the PKCE verifier only after HMAC validation passes. + verifier, ok := s.pkceVerifierStore.LoadAndDelete(state) + if !ok { + return "", "", errors.New("no verifier for state") + } + + return verifier, redirectURL, nil +} + +// GenerateSessionToken creates a signed session JWT for the given domain and user. +func (s *ProxyServiceServer) GenerateSessionToken(ctx context.Context, domain, userID string, method proxyauth.Method) (string, error) { + // Find the service by domain to get its signing key + services, err := s.serviceManager.GetGlobalServices(ctx) + if err != nil { + return "", fmt.Errorf("get services: %w", err) + } + + var service *rpservice.Service + for _, svc := range services { + if svc.Domain == domain { + service = svc + break + } + } + if service == nil { + return "", fmt.Errorf("service not found for domain: %s", domain) + } + + if service.SessionPrivateKey == "" { + return "", fmt.Errorf("no session key configured for domain: %s", domain) + } + + return sessionkey.SignToken( + service.SessionPrivateKey, + userID, + domain, + method, + proxyauth.DefaultSessionExpiry, + ) +} + +// ValidateUserGroupAccess checks if a user has access to a service. +// It looks up the service within the user's account only, then optionally checks +// group membership if BearerAuth with DistributionGroups is configured. +func (s *ProxyServiceServer) ValidateUserGroupAccess(ctx context.Context, domain, userID string) error { + user, err := s.usersManager.GetUser(ctx, userID) + if err != nil { + return fmt.Errorf("user not found: %s", userID) + } + + service, err := s.getAccountServiceByDomain(ctx, user.AccountID, domain) + if err != nil { + return err + } + + if service.Auth.BearerAuth == nil || !service.Auth.BearerAuth.Enabled { + return nil + } + + allowedGroups := service.Auth.BearerAuth.DistributionGroups + if len(allowedGroups) == 0 { + return nil + } + + allowedSet := make(map[string]bool, len(allowedGroups)) + for _, groupID := range allowedGroups { + allowedSet[groupID] = true + } + + for _, groupID := range user.AutoGroups { + if allowedSet[groupID] { + log.WithFields(log.Fields{ + "user_id": user.Id, + "group_id": groupID, + "domain": domain, + }).Debug("User granted access via group membership") + return nil + } + } + + return fmt.Errorf("user %s not in allowed groups for domain %s", user.Id, domain) +} + +func (s *ProxyServiceServer) getAccountServiceByDomain(ctx context.Context, accountID, domain string) (*rpservice.Service, error) { + services, err := s.serviceManager.GetAccountServices(ctx, accountID) + if err != nil { + return nil, fmt.Errorf("get account services: %w", err) + } + + for _, service := range services { + if service.Domain == domain { + return service, nil + } + } + + return nil, fmt.Errorf("service not found for domain %s in account %s", domain, accountID) +} + +// ValidateSession validates a session token and checks if the user has access to the domain. +func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.ValidateSessionRequest) (*proto.ValidateSessionResponse, error) { + domain := req.GetDomain() + sessionToken := req.GetSessionToken() + + if domain == "" || sessionToken == "" { + return &proto.ValidateSessionResponse{ + Valid: false, + DeniedReason: "missing domain or session_token", + }, nil + } + + service, err := s.getServiceByDomain(ctx, domain) + if err != nil { + log.WithFields(log.Fields{ + "domain": domain, + "error": err.Error(), + }).Debug("ValidateSession: service not found") + //nolint:nilerr + return &proto.ValidateSessionResponse{ + Valid: false, + DeniedReason: "service_not_found", + }, nil + } + + pubKeyBytes, err := base64.StdEncoding.DecodeString(service.SessionPublicKey) + if err != nil { + log.WithFields(log.Fields{ + "domain": domain, + "error": err.Error(), + }).Error("ValidateSession: decode public key") + //nolint:nilerr + return &proto.ValidateSessionResponse{ + Valid: false, + DeniedReason: "invalid_service_config", + }, nil + } + + userID, _, err := proxyauth.ValidateSessionJWT(sessionToken, domain, pubKeyBytes) + if err != nil { + log.WithFields(log.Fields{ + "domain": domain, + "error": err.Error(), + }).Debug("ValidateSession: invalid session token") + //nolint:nilerr + return &proto.ValidateSessionResponse{ + Valid: false, + DeniedReason: "invalid_token", + }, nil + } + + user, err := s.usersManager.GetUser(ctx, userID) + if err != nil { + log.WithFields(log.Fields{ + "domain": domain, + "user_id": userID, + "error": err.Error(), + }).Debug("ValidateSession: user not found") + //nolint:nilerr + return &proto.ValidateSessionResponse{ + Valid: false, + DeniedReason: "user_not_found", + }, nil + } + + if user.AccountID != service.AccountID { + log.WithFields(log.Fields{ + "domain": domain, + "user_id": userID, + "user_account": user.AccountID, + "service_account": service.AccountID, + }).Debug("ValidateSession: user account mismatch") + //nolint:nilerr + return &proto.ValidateSessionResponse{ + Valid: false, + DeniedReason: "account_mismatch", + }, nil + } + + if err := s.checkGroupAccess(service, user); err != nil { + log.WithFields(log.Fields{ + "domain": domain, + "user_id": userID, + "error": err.Error(), + }).Debug("ValidateSession: access denied") + //nolint:nilerr + return &proto.ValidateSessionResponse{ + Valid: false, + UserId: user.Id, + UserEmail: user.Email, + DeniedReason: "not_in_group", + }, nil + } + + log.WithFields(log.Fields{ + "domain": domain, + "user_id": userID, + "email": user.Email, + }).Debug("ValidateSession: access granted") + + return &proto.ValidateSessionResponse{ + Valid: true, + UserId: user.Id, + UserEmail: user.Email, + }, nil +} + +func (s *ProxyServiceServer) getServiceByDomain(ctx context.Context, domain string) (*rpservice.Service, error) { + services, err := s.serviceManager.GetGlobalServices(ctx) + if err != nil { + return nil, fmt.Errorf("get services: %w", err) + } + + for _, service := range services { + if service.Domain == domain { + return service, nil + } + } + + return nil, fmt.Errorf("service not found for domain: %s", domain) +} + +func (s *ProxyServiceServer) checkGroupAccess(service *rpservice.Service, user *types.User) error { + if service.Auth.BearerAuth == nil || !service.Auth.BearerAuth.Enabled { + return nil + } + + allowedGroups := service.Auth.BearerAuth.DistributionGroups + if len(allowedGroups) == 0 { + return nil + } + + allowedSet := make(map[string]bool, len(allowedGroups)) + for _, groupID := range allowedGroups { + allowedSet[groupID] = true + } + + for _, groupID := range user.AutoGroups { + if allowedSet[groupID] { + return nil + } + } + + return fmt.Errorf("user not in allowed groups") +} + +func ptr[T any](v T) *T { return &v } diff --git a/management/internals/shared/grpc/proxy_auth.go b/management/internals/shared/grpc/proxy_auth.go new file mode 100644 index 000000000..dd593dfa0 --- /dev/null +++ b/management/internals/shared/grpc/proxy_auth.go @@ -0,0 +1,234 @@ +package grpc + +import ( + "context" + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" +) + +const ( + // lastUsedUpdateInterval is the minimum interval between last_used updates for the same token. + lastUsedUpdateInterval = time.Minute + // lastUsedCleanupInterval is how often stale lastUsed entries are removed. + lastUsedCleanupInterval = 2 * time.Minute +) + +type proxyTokenContextKey struct{} + +// ProxyTokenContextKey is the typed key used to store validated token info in context. +var ProxyTokenContextKey = proxyTokenContextKey{} + +// proxyTokenID identifies a proxy access token by its database ID. +type proxyTokenID = string + +// proxyTokenStore defines the store interface needed for token validation +type proxyTokenStore interface { + GetProxyAccessTokenByHashedToken(ctx context.Context, lockStrength store.LockingStrength, hashedToken types.HashedProxyToken) (*types.ProxyAccessToken, error) + MarkProxyAccessTokenUsed(ctx context.Context, tokenID string) error +} + +// proxyAuthInterceptor holds state for proxy authentication interceptors. +type proxyAuthInterceptor struct { + store proxyTokenStore + failureLimiter *authFailureLimiter + + // lastUsedMu protects lastUsedTimes + lastUsedMu sync.Mutex + lastUsedTimes map[proxyTokenID]time.Time + cancel context.CancelFunc +} + +func newProxyAuthInterceptor(tokenStore proxyTokenStore) *proxyAuthInterceptor { + ctx, cancel := context.WithCancel(context.Background()) + i := &proxyAuthInterceptor{ + store: tokenStore, + failureLimiter: newAuthFailureLimiter(), + lastUsedTimes: make(map[proxyTokenID]time.Time), + cancel: cancel, + } + go i.lastUsedCleanupLoop(ctx) + return i +} + +// NewProxyAuthInterceptors creates gRPC unary and stream interceptors that validate proxy access tokens. +// They only intercept ProxyService methods. Both interceptors share state for last-used and failure rate limiting. +// The returned close function must be called on shutdown to stop background goroutines. +func NewProxyAuthInterceptors(tokenStore proxyTokenStore) (grpc.UnaryServerInterceptor, grpc.StreamServerInterceptor, func()) { + interceptor := newProxyAuthInterceptor(tokenStore) + + unary := func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { + if !strings.HasPrefix(info.FullMethod, "/management.ProxyService/") { + return handler(ctx, req) + } + + token, err := interceptor.validateProxyToken(ctx) + if err != nil { + // Log auth failures explicitly; gRPC doesn't log these by default. + log.WithContext(ctx).Warnf("proxy auth failed: %v", err) + return nil, err + } + + ctx = context.WithValue(ctx, ProxyTokenContextKey, token) + return handler(ctx, req) + } + + stream := func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { + if !strings.HasPrefix(info.FullMethod, "/management.ProxyService/") { + return handler(srv, ss) + } + + token, err := interceptor.validateProxyToken(ss.Context()) + if err != nil { + // Log auth failures explicitly; gRPC doesn't log these by default. + log.WithContext(ss.Context()).Warnf("proxy auth failed: %v", err) + return err + } + + ctx := context.WithValue(ss.Context(), ProxyTokenContextKey, token) + wrapped := &wrappedServerStream{ + ServerStream: ss, + ctx: ctx, + } + + return handler(srv, wrapped) + } + + return unary, stream, interceptor.close +} + +func (i *proxyAuthInterceptor) validateProxyToken(ctx context.Context) (*types.ProxyAccessToken, error) { + clientIP := PeerIPFromContext(ctx) + + if clientIP != "" && i.failureLimiter.isLimited(clientIP) { + return nil, status.Errorf(codes.ResourceExhausted, "too many failed authentication attempts") + } + + token, err := i.doValidateProxyToken(ctx) + if err != nil { + if clientIP != "" { + i.failureLimiter.recordFailure(clientIP) + } + return nil, err + } + + i.maybeUpdateLastUsed(ctx, token.ID) + + return token, nil +} + +func (i *proxyAuthInterceptor) doValidateProxyToken(ctx context.Context) (*types.ProxyAccessToken, error) { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return nil, status.Errorf(codes.Unauthenticated, "missing metadata") + } + + authValues := md.Get("authorization") + if len(authValues) == 0 { + return nil, status.Errorf(codes.Unauthenticated, "missing authorization header") + } + + authValue := authValues[0] + if !strings.HasPrefix(authValue, "Bearer ") { + return nil, status.Errorf(codes.Unauthenticated, "invalid authorization format") + } + + plainToken := types.PlainProxyToken(strings.TrimPrefix(authValue, "Bearer ")) + + if err := plainToken.Validate(); err != nil { + return nil, status.Errorf(codes.Unauthenticated, "invalid token format") + } + + token, err := i.store.GetProxyAccessTokenByHashedToken(ctx, store.LockingStrengthNone, plainToken.Hash()) + if err != nil { + return nil, status.Errorf(codes.Unauthenticated, "invalid token") + } + + // TODO: Enforce AccountID scope for "bring your own proxy" feature. + // Currently tokens are management-wide; AccountID field is reserved for future use. + + if !token.IsValid() { + return nil, status.Errorf(codes.Unauthenticated, "token expired or revoked") + } + + return token, nil +} + +// maybeUpdateLastUsed updates the last_used timestamp if enough time has passed since the last update. +func (i *proxyAuthInterceptor) maybeUpdateLastUsed(ctx context.Context, tokenID string) { + now := time.Now() + + i.lastUsedMu.Lock() + lastUpdate, exists := i.lastUsedTimes[tokenID] + if exists && now.Sub(lastUpdate) < lastUsedUpdateInterval { + i.lastUsedMu.Unlock() + return + } + i.lastUsedTimes[tokenID] = now + i.lastUsedMu.Unlock() + + if err := i.store.MarkProxyAccessTokenUsed(ctx, tokenID); err != nil { + log.WithContext(ctx).Debugf("failed to mark proxy token as used: %v", err) + } +} + +func (i *proxyAuthInterceptor) lastUsedCleanupLoop(ctx context.Context) { + ticker := time.NewTicker(lastUsedCleanupInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + i.cleanupStaleLastUsed() + case <-ctx.Done(): + return + } + } +} + +// cleanupStaleLastUsed removes entries older than 2x the update interval. +func (i *proxyAuthInterceptor) cleanupStaleLastUsed() { + i.lastUsedMu.Lock() + defer i.lastUsedMu.Unlock() + + now := time.Now() + staleThreshold := 2 * lastUsedUpdateInterval + for id, lastUpdate := range i.lastUsedTimes { + if now.Sub(lastUpdate) > staleThreshold { + delete(i.lastUsedTimes, id) + } + } +} + +func (i *proxyAuthInterceptor) close() { + i.cancel() + i.failureLimiter.stop() +} + +// GetProxyTokenFromContext retrieves the validated proxy token from the context +func GetProxyTokenFromContext(ctx context.Context) *types.ProxyAccessToken { + token, ok := ctx.Value(ProxyTokenContextKey).(*types.ProxyAccessToken) + if !ok { + return nil + } + return token +} + +// wrappedServerStream wraps a grpc.ServerStream to provide a custom context +type wrappedServerStream struct { + grpc.ServerStream + ctx context.Context +} + +func (w *wrappedServerStream) Context() context.Context { + return w.ctx +} diff --git a/management/internals/shared/grpc/proxy_auth_ratelimit.go b/management/internals/shared/grpc/proxy_auth_ratelimit.go new file mode 100644 index 000000000..78ab1bd20 --- /dev/null +++ b/management/internals/shared/grpc/proxy_auth_ratelimit.go @@ -0,0 +1,134 @@ +package grpc + +import ( + "context" + "net" + "sync" + "time" + + "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/realip" + "golang.org/x/time/rate" + "google.golang.org/grpc/peer" +) + +const ( + // proxyAuthFailureBurst is the maximum number of failed attempts before rate limiting kicks in. + proxyAuthFailureBurst = 5 + // proxyAuthLimiterCleanup is how often stale limiters are removed. + proxyAuthLimiterCleanup = 5 * time.Minute + // proxyAuthLimiterTTL is how long a limiter is kept after the last failure. + proxyAuthLimiterTTL = 15 * time.Minute +) + +// defaultProxyAuthFailureRate is the token replenishment rate for failed auth attempts. +// One token every 12 seconds = 5 per minute. +var defaultProxyAuthFailureRate = rate.Every(12 * time.Second) + +// clientIP identifies a client by its IP address for rate limiting purposes. +type clientIP = string + +type limiterEntry struct { + limiter *rate.Limiter + lastAccess time.Time +} + +// authFailureLimiter tracks per-IP rate limits for failed proxy authentication attempts. +type authFailureLimiter struct { + mu sync.Mutex + limiters map[clientIP]*limiterEntry + failureRate rate.Limit + cancel context.CancelFunc +} + +func newAuthFailureLimiter() *authFailureLimiter { + return newAuthFailureLimiterWithRate(defaultProxyAuthFailureRate) +} + +func newAuthFailureLimiterWithRate(failureRate rate.Limit) *authFailureLimiter { + ctx, cancel := context.WithCancel(context.Background()) + l := &authFailureLimiter{ + limiters: make(map[clientIP]*limiterEntry), + failureRate: failureRate, + cancel: cancel, + } + go l.cleanupLoop(ctx) + return l +} + +// isLimited returns true if the given IP has exhausted its failure budget. +func (l *authFailureLimiter) isLimited(ip clientIP) bool { + l.mu.Lock() + defer l.mu.Unlock() + + entry, exists := l.limiters[ip] + if !exists { + return false + } + + return entry.limiter.Tokens() < 1 +} + +// recordFailure consumes a token from the rate limiter for the given IP. +func (l *authFailureLimiter) recordFailure(ip clientIP) { + l.mu.Lock() + defer l.mu.Unlock() + + now := time.Now() + entry, exists := l.limiters[ip] + if !exists { + entry = &limiterEntry{ + limiter: rate.NewLimiter(l.failureRate, proxyAuthFailureBurst), + } + l.limiters[ip] = entry + } + entry.lastAccess = now + entry.limiter.Allow() +} + +func (l *authFailureLimiter) cleanupLoop(ctx context.Context) { + ticker := time.NewTicker(proxyAuthLimiterCleanup) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + l.cleanup() + case <-ctx.Done(): + return + } + } +} + +func (l *authFailureLimiter) cleanup() { + l.mu.Lock() + defer l.mu.Unlock() + + now := time.Now() + for ip, entry := range l.limiters { + if now.Sub(entry.lastAccess) > proxyAuthLimiterTTL { + delete(l.limiters, ip) + } + } +} + +func (l *authFailureLimiter) stop() { + l.cancel() +} + +// PeerIPFromContext extracts the client IP from the gRPC context. +// Uses realip (from trusted proxy headers) first, falls back to the transport peer address. +func PeerIPFromContext(ctx context.Context) string { + if addr, ok := realip.FromContext(ctx); ok { + return addr.String() + } + + if p, ok := peer.FromContext(ctx); ok { + host, _, err := net.SplitHostPort(p.Addr.String()) + if err != nil { + return p.Addr.String() + } + return host + } + + return "" +} diff --git a/management/internals/shared/grpc/proxy_auth_ratelimit_test.go b/management/internals/shared/grpc/proxy_auth_ratelimit_test.go new file mode 100644 index 000000000..3577baeb8 --- /dev/null +++ b/management/internals/shared/grpc/proxy_auth_ratelimit_test.go @@ -0,0 +1,98 @@ +package grpc + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/time/rate" +) + +func TestAuthFailureLimiter_NotLimitedInitially(t *testing.T) { + l := newAuthFailureLimiter() + defer l.stop() + + assert.False(t, l.isLimited("192.168.1.1"), "new IP should not be rate limited") +} + +func TestAuthFailureLimiter_LimitedAfterBurst(t *testing.T) { + l := newAuthFailureLimiter() + defer l.stop() + + ip := "192.168.1.1" + for i := 0; i < proxyAuthFailureBurst; i++ { + l.recordFailure(ip) + } + + assert.True(t, l.isLimited(ip), "IP should be limited after exhausting burst") +} + +func TestAuthFailureLimiter_DifferentIPsIndependent(t *testing.T) { + l := newAuthFailureLimiter() + defer l.stop() + + for i := 0; i < proxyAuthFailureBurst; i++ { + l.recordFailure("192.168.1.1") + } + + assert.True(t, l.isLimited("192.168.1.1")) + assert.False(t, l.isLimited("192.168.1.2"), "different IP should not be affected") +} + +func TestAuthFailureLimiter_RecoveryOverTime(t *testing.T) { + l := newAuthFailureLimiterWithRate(rate.Limit(100)) // 100 tokens/sec for fast recovery + defer l.stop() + + ip := "10.0.0.1" + + // Exhaust burst + for i := 0; i < proxyAuthFailureBurst; i++ { + l.recordFailure(ip) + } + require.True(t, l.isLimited(ip)) + + // Wait for token replenishment + time.Sleep(50 * time.Millisecond) + + assert.False(t, l.isLimited(ip), "should recover after tokens replenish") +} + +func TestAuthFailureLimiter_Cleanup(t *testing.T) { + l := newAuthFailureLimiter() + defer l.stop() + + l.recordFailure("10.0.0.1") + + l.mu.Lock() + require.Len(t, l.limiters, 1) + // Backdate the entry so it looks stale + l.limiters["10.0.0.1"].lastAccess = time.Now().Add(-proxyAuthLimiterTTL - time.Minute) + l.mu.Unlock() + + l.cleanup() + + l.mu.Lock() + assert.Empty(t, l.limiters, "stale entries should be cleaned up") + l.mu.Unlock() +} + +func TestAuthFailureLimiter_CleanupKeepsFresh(t *testing.T) { + l := newAuthFailureLimiter() + defer l.stop() + + l.recordFailure("10.0.0.1") + l.recordFailure("10.0.0.2") + + l.mu.Lock() + // Only backdate one entry + l.limiters["10.0.0.1"].lastAccess = time.Now().Add(-proxyAuthLimiterTTL - time.Minute) + l.mu.Unlock() + + l.cleanup() + + l.mu.Lock() + assert.Len(t, l.limiters, 1, "only stale entries should be removed") + assert.Contains(t, l.limiters, "10.0.0.2") + l.mu.Unlock() +} diff --git a/management/internals/shared/grpc/proxy_group_access_test.go b/management/internals/shared/grpc/proxy_group_access_test.go new file mode 100644 index 000000000..22fe4506b --- /dev/null +++ b/management/internals/shared/grpc/proxy_group_access_test.go @@ -0,0 +1,399 @@ +package grpc + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/types" +) + +type mockReverseProxyManager struct { + proxiesByAccount map[string][]*service.Service + err error +} + +func (m *mockReverseProxyManager) DeleteAllServices(ctx context.Context, accountID, userID string) error { + return nil +} + +func (m *mockReverseProxyManager) GetAccountServices(ctx context.Context, accountID string) ([]*service.Service, error) { + if m.err != nil { + return nil, m.err + } + return m.proxiesByAccount[accountID], nil +} + +func (m *mockReverseProxyManager) GetGlobalServices(ctx context.Context) ([]*service.Service, error) { + return nil, nil +} + +func (m *mockReverseProxyManager) GetAllServices(ctx context.Context, accountID, userID string) ([]*service.Service, error) { + return []*service.Service{}, nil +} + +func (m *mockReverseProxyManager) GetService(ctx context.Context, accountID, userID, reverseProxyID string) (*service.Service, error) { + return &service.Service{}, nil +} + +func (m *mockReverseProxyManager) CreateService(ctx context.Context, accountID, userID string, rp *service.Service) (*service.Service, error) { + return &service.Service{}, nil +} + +func (m *mockReverseProxyManager) UpdateService(ctx context.Context, accountID, userID string, rp *service.Service) (*service.Service, error) { + return &service.Service{}, nil +} + +func (m *mockReverseProxyManager) DeleteService(ctx context.Context, accountID, userID, reverseProxyID string) error { + return nil +} + +func (m *mockReverseProxyManager) SetCertificateIssuedAt(ctx context.Context, accountID, reverseProxyID string) error { + return nil +} + +func (m *mockReverseProxyManager) SetStatus(ctx context.Context, accountID, reverseProxyID string, status service.Status) error { + return nil +} + +func (m *mockReverseProxyManager) ReloadAllServicesForAccount(ctx context.Context, accountID string) error { + return nil +} + +func (m *mockReverseProxyManager) ReloadService(ctx context.Context, accountID, reverseProxyID string) error { + return nil +} + +func (m *mockReverseProxyManager) GetServiceByID(ctx context.Context, accountID, reverseProxyID string) (*service.Service, error) { + return &service.Service{}, nil +} + +func (m *mockReverseProxyManager) GetServiceIDByTargetID(_ context.Context, _, _ string) (string, error) { + return "", nil +} + +func (m *mockReverseProxyManager) CreateServiceFromPeer(_ context.Context, _, _ string, _ *service.ExposeServiceRequest) (*service.ExposeServiceResponse, error) { + return &service.ExposeServiceResponse{}, nil +} + +func (m *mockReverseProxyManager) RenewServiceFromPeer(_ context.Context, _, _, _ string) error { + return nil +} + +func (m *mockReverseProxyManager) StopServiceFromPeer(_ context.Context, _, _, _ string) error { + return nil +} + +func (m *mockReverseProxyManager) StartExposeReaper(_ context.Context) {} + +type mockUsersManager struct { + users map[string]*types.User + err error +} + +func (m *mockUsersManager) GetUser(ctx context.Context, userID string) (*types.User, error) { + if m.err != nil { + return nil, m.err + } + user, ok := m.users[userID] + if !ok { + return nil, errors.New("user not found") + } + return user, nil +} + +func TestValidateUserGroupAccess(t *testing.T) { + tests := []struct { + name string + domain string + userID string + proxiesByAccount map[string][]*service.Service + users map[string]*types.User + proxyErr error + userErr error + expectErr bool + expectErrMsg string + }{ + { + name: "user not found", + domain: "app.example.com", + userID: "unknown-user", + proxiesByAccount: map[string][]*service.Service{ + "account1": {{Domain: "app.example.com", AccountID: "account1"}}, + }, + users: map[string]*types.User{}, + expectErr: true, + expectErrMsg: "user not found", + }, + { + name: "proxy not found in user's account", + domain: "app.example.com", + userID: "user1", + proxiesByAccount: map[string][]*service.Service{}, + users: map[string]*types.User{ + "user1": {Id: "user1", AccountID: "account1"}, + }, + expectErr: true, + expectErrMsg: "service not found", + }, + { + name: "proxy exists in different account - not accessible", + domain: "app.example.com", + userID: "user1", + proxiesByAccount: map[string][]*service.Service{ + "account2": {{Domain: "app.example.com", AccountID: "account2"}}, + }, + users: map[string]*types.User{ + "user1": {Id: "user1", AccountID: "account1"}, + }, + expectErr: true, + expectErrMsg: "service not found", + }, + { + name: "no bearer auth configured - same account allows access", + domain: "app.example.com", + userID: "user1", + proxiesByAccount: map[string][]*service.Service{ + "account1": {{Domain: "app.example.com", AccountID: "account1", Auth: service.AuthConfig{}}}, + }, + users: map[string]*types.User{ + "user1": {Id: "user1", AccountID: "account1"}, + }, + expectErr: false, + }, + { + name: "bearer auth disabled - same account allows access", + domain: "app.example.com", + userID: "user1", + proxiesByAccount: map[string][]*service.Service{ + "account1": {{ + Domain: "app.example.com", + AccountID: "account1", + Auth: service.AuthConfig{ + BearerAuth: &service.BearerAuthConfig{Enabled: false}, + }, + }}, + }, + users: map[string]*types.User{ + "user1": {Id: "user1", AccountID: "account1"}, + }, + expectErr: false, + }, + { + name: "bearer auth enabled but no groups configured - same account allows access", + domain: "app.example.com", + userID: "user1", + proxiesByAccount: map[string][]*service.Service{ + "account1": {{ + Domain: "app.example.com", + AccountID: "account1", + Auth: service.AuthConfig{ + BearerAuth: &service.BearerAuthConfig{ + Enabled: true, + DistributionGroups: []string{}, + }, + }, + }}, + }, + users: map[string]*types.User{ + "user1": {Id: "user1", AccountID: "account1"}, + }, + expectErr: false, + }, + { + name: "user not in allowed groups", + domain: "app.example.com", + userID: "user1", + proxiesByAccount: map[string][]*service.Service{ + "account1": {{ + Domain: "app.example.com", + AccountID: "account1", + Auth: service.AuthConfig{ + BearerAuth: &service.BearerAuthConfig{ + Enabled: true, + DistributionGroups: []string{"group1", "group2"}, + }, + }, + }}, + }, + users: map[string]*types.User{ + "user1": {Id: "user1", AccountID: "account1", AutoGroups: []string{"group3", "group4"}}, + }, + expectErr: true, + expectErrMsg: "not in allowed groups", + }, + { + name: "user in one of the allowed groups - allow access", + domain: "app.example.com", + userID: "user1", + proxiesByAccount: map[string][]*service.Service{ + "account1": {{ + Domain: "app.example.com", + AccountID: "account1", + Auth: service.AuthConfig{ + BearerAuth: &service.BearerAuthConfig{ + Enabled: true, + DistributionGroups: []string{"group1", "group2"}, + }, + }, + }}, + }, + users: map[string]*types.User{ + "user1": {Id: "user1", AccountID: "account1", AutoGroups: []string{"group2", "group3"}}, + }, + expectErr: false, + }, + { + name: "user in all allowed groups - allow access", + domain: "app.example.com", + userID: "user1", + proxiesByAccount: map[string][]*service.Service{ + "account1": {{ + Domain: "app.example.com", + AccountID: "account1", + Auth: service.AuthConfig{ + BearerAuth: &service.BearerAuthConfig{ + Enabled: true, + DistributionGroups: []string{"group1", "group2"}, + }, + }, + }}, + }, + users: map[string]*types.User{ + "user1": {Id: "user1", AccountID: "account1", AutoGroups: []string{"group1", "group2", "group3"}}, + }, + expectErr: false, + }, + { + name: "proxy manager error", + domain: "app.example.com", + userID: "user1", + proxiesByAccount: nil, + proxyErr: errors.New("database error"), + users: map[string]*types.User{ + "user1": {Id: "user1", AccountID: "account1"}, + }, + expectErr: true, + expectErrMsg: "get account services", + }, + { + name: "multiple proxies in account - finds correct one", + domain: "app2.example.com", + userID: "user1", + proxiesByAccount: map[string][]*service.Service{ + "account1": { + {Domain: "app1.example.com", AccountID: "account1"}, + {Domain: "app2.example.com", AccountID: "account1", Auth: service.AuthConfig{}}, + {Domain: "app3.example.com", AccountID: "account1"}, + }, + }, + users: map[string]*types.User{ + "user1": {Id: "user1", AccountID: "account1"}, + }, + expectErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := &ProxyServiceServer{ + serviceManager: &mockReverseProxyManager{ + proxiesByAccount: tt.proxiesByAccount, + err: tt.proxyErr, + }, + usersManager: &mockUsersManager{ + users: tt.users, + err: tt.userErr, + }, + } + + err := server.ValidateUserGroupAccess(context.Background(), tt.domain, tt.userID) + + if tt.expectErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectErrMsg) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestGetAccountProxyByDomain(t *testing.T) { + tests := []struct { + name string + accountID string + domain string + proxiesByAccount map[string][]*service.Service + err error + expectProxy bool + expectErr bool + }{ + { + name: "proxy found", + accountID: "account1", + domain: "app.example.com", + proxiesByAccount: map[string][]*service.Service{ + "account1": { + {Domain: "other.example.com", AccountID: "account1"}, + {Domain: "app.example.com", AccountID: "account1"}, + }, + }, + expectProxy: true, + expectErr: false, + }, + { + name: "proxy not found in account", + accountID: "account1", + domain: "unknown.example.com", + proxiesByAccount: map[string][]*service.Service{ + "account1": {{Domain: "app.example.com", AccountID: "account1"}}, + }, + expectProxy: false, + expectErr: true, + }, + { + name: "empty proxy list for account", + accountID: "account1", + domain: "app.example.com", + proxiesByAccount: map[string][]*service.Service{}, + expectProxy: false, + expectErr: true, + }, + { + name: "manager error", + accountID: "account1", + domain: "app.example.com", + proxiesByAccount: nil, + err: errors.New("database error"), + expectProxy: false, + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := &ProxyServiceServer{ + serviceManager: &mockReverseProxyManager{ + proxiesByAccount: tt.proxiesByAccount, + err: tt.err, + }, + } + + proxy, err := server.getAccountServiceByDomain(context.Background(), tt.accountID, tt.domain) + + if tt.expectErr { + require.Error(t, err) + assert.Nil(t, proxy) + } else { + require.NoError(t, err) + require.NotNil(t, proxy) + assert.Equal(t, tt.domain, proxy.Domain) + } + }) + } +} diff --git a/management/internals/shared/grpc/proxy_test.go b/management/internals/shared/grpc/proxy_test.go new file mode 100644 index 000000000..1a4ea3330 --- /dev/null +++ b/management/internals/shared/grpc/proxy_test.go @@ -0,0 +1,652 @@ +package grpc + +import ( + "context" + "crypto/rand" + "encoding/base64" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + "github.com/netbirdio/netbird/shared/management/proto" +) + +type testProxyController struct { + mu sync.Mutex + clusterProxies map[string]map[string]struct{} +} + +func newTestProxyController() *testProxyController { + return &testProxyController{ + clusterProxies: make(map[string]map[string]struct{}), + } +} + +func (c *testProxyController) SendServiceUpdateToCluster(_ context.Context, _ string, _ *proto.ProxyMapping, _ string) { +} + +func (c *testProxyController) GetOIDCValidationConfig() proxy.OIDCValidationConfig { + return proxy.OIDCValidationConfig{} +} + +func (c *testProxyController) RegisterProxyToCluster(_ context.Context, clusterAddr, proxyID string) error { + c.mu.Lock() + defer c.mu.Unlock() + if _, ok := c.clusterProxies[clusterAddr]; !ok { + c.clusterProxies[clusterAddr] = make(map[string]struct{}) + } + c.clusterProxies[clusterAddr][proxyID] = struct{}{} + return nil +} + +func (c *testProxyController) UnregisterProxyFromCluster(_ context.Context, clusterAddr, proxyID string) error { + c.mu.Lock() + defer c.mu.Unlock() + if proxies, ok := c.clusterProxies[clusterAddr]; ok { + delete(proxies, proxyID) + } + return nil +} + +func (c *testProxyController) ClusterSupportsCustomPorts(_ string) *bool { + return ptr(true) +} + +func (c *testProxyController) GetProxiesForCluster(clusterAddr string) []string { + c.mu.Lock() + defer c.mu.Unlock() + proxies, ok := c.clusterProxies[clusterAddr] + if !ok { + return nil + } + result := make([]string, 0, len(proxies)) + for id := range proxies { + result = append(result, id) + } + return result +} + +// registerFakeProxy adds a fake proxy connection to the server's internal maps +// and returns the channel where messages will be received. +func registerFakeProxy(s *ProxyServiceServer, proxyID, clusterAddr string) chan *proto.GetMappingUpdateResponse { + return registerFakeProxyWithCaps(s, proxyID, clusterAddr, nil) +} + +// registerFakeProxyWithCaps adds a fake proxy connection with explicit capabilities. +func registerFakeProxyWithCaps(s *ProxyServiceServer, proxyID, clusterAddr string, caps *proto.ProxyCapabilities) chan *proto.GetMappingUpdateResponse { + ch := make(chan *proto.GetMappingUpdateResponse, 10) + conn := &proxyConnection{ + proxyID: proxyID, + address: clusterAddr, + capabilities: caps, + sendChan: ch, + } + s.connectedProxies.Store(proxyID, conn) + + _ = s.proxyController.RegisterProxyToCluster(context.Background(), clusterAddr, proxyID) + + return ch +} + +// drainMapping drains a single ProxyMapping from the channel. +func drainMapping(ch chan *proto.GetMappingUpdateResponse) *proto.ProxyMapping { + select { + case resp := <-ch: + if len(resp.Mapping) > 0 { + return resp.Mapping[0] + } + return nil + case <-time.After(time.Second): + return nil + } +} + +// drainEmpty checks if a channel has no message within timeout. +func drainEmpty(ch chan *proto.GetMappingUpdateResponse) bool { + select { + case <-ch: + return false + case <-time.After(100 * time.Millisecond): + return true + } +} + +func TestSendServiceUpdateToCluster_UniqueTokensPerProxy(t *testing.T) { + ctx := context.Background() + tokenStore, err := NewOneTimeTokenStore(ctx, time.Hour, 10*time.Minute, 100) + require.NoError(t, err) + + pkceStore, err := NewPKCEVerifierStore(ctx, 10*time.Minute, 10*time.Minute, 100) + require.NoError(t, err) + + s := &ProxyServiceServer{ + tokenStore: tokenStore, + pkceVerifierStore: pkceStore, + } + s.SetProxyController(newTestProxyController()) + + const cluster = "proxy.example.com" + const numProxies = 3 + + channels := make([]chan *proto.GetMappingUpdateResponse, numProxies) + for i := range numProxies { + id := "proxy-" + string(rune('a'+i)) + channels[i] = registerFakeProxy(s, id, cluster) + } + + mapping := &proto.ProxyMapping{ + Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, + Id: "service-1", + AccountId: "account-1", + Domain: "test.example.com", + Path: []*proto.PathMapping{ + {Path: "/", Target: "http://10.0.0.1:8080/"}, + }, + } + + s.SendServiceUpdateToCluster(context.Background(), mapping, cluster) + + tokens := make([]string, numProxies) + for i, ch := range channels { + msg := drainMapping(ch) + require.NotNil(t, msg, "proxy %d should receive a message", i) + assert.Equal(t, mapping.Domain, msg.Domain) + assert.Equal(t, mapping.Id, msg.Id) + assert.NotEmpty(t, msg.AuthToken, "proxy %d should have a non-empty token", i) + tokens[i] = msg.AuthToken + } + + // All tokens must be unique + tokenSet := make(map[string]struct{}) + for i, tok := range tokens { + _, exists := tokenSet[tok] + assert.False(t, exists, "proxy %d got duplicate token", i) + tokenSet[tok] = struct{}{} + } + + // Each token must be independently consumable + for i, tok := range tokens { + err := tokenStore.ValidateAndConsume(tok, "account-1", "service-1") + assert.NoError(t, err, "proxy %d token should validate successfully", i) + } +} + +func TestSendServiceUpdateToCluster_DeleteNoToken(t *testing.T) { + ctx := context.Background() + tokenStore, err := NewOneTimeTokenStore(ctx, time.Hour, 10*time.Minute, 100) + require.NoError(t, err) + + pkceStore, err := NewPKCEVerifierStore(ctx, 10*time.Minute, 10*time.Minute, 100) + require.NoError(t, err) + + s := &ProxyServiceServer{ + tokenStore: tokenStore, + pkceVerifierStore: pkceStore, + } + s.SetProxyController(newTestProxyController()) + + const cluster = "proxy.example.com" + ch1 := registerFakeProxy(s, "proxy-a", cluster) + ch2 := registerFakeProxy(s, "proxy-b", cluster) + + mapping := &proto.ProxyMapping{ + Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, + Id: "service-1", + AccountId: "account-1", + Domain: "test.example.com", + } + + s.SendServiceUpdateToCluster(context.Background(), mapping, cluster) + + msg1 := drainMapping(ch1) + msg2 := drainMapping(ch2) + require.NotNil(t, msg1) + require.NotNil(t, msg2) + + // Delete operations should not generate tokens + assert.Empty(t, msg1.AuthToken) + assert.Empty(t, msg2.AuthToken) +} + +func TestSendServiceUpdate_UniqueTokensPerProxy(t *testing.T) { + ctx := context.Background() + tokenStore, err := NewOneTimeTokenStore(ctx, time.Hour, 10*time.Minute, 100) + require.NoError(t, err) + + pkceStore, err := NewPKCEVerifierStore(ctx, 10*time.Minute, 10*time.Minute, 100) + require.NoError(t, err) + + s := &ProxyServiceServer{ + tokenStore: tokenStore, + pkceVerifierStore: pkceStore, + } + s.SetProxyController(newTestProxyController()) + + // Register proxies in different clusters (SendServiceUpdate broadcasts to all) + ch1 := registerFakeProxy(s, "proxy-a", "cluster-a") + ch2 := registerFakeProxy(s, "proxy-b", "cluster-b") + + mapping := &proto.ProxyMapping{ + Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, + Id: "service-1", + AccountId: "account-1", + Domain: "test.example.com", + } + + update := &proto.GetMappingUpdateResponse{ + Mapping: []*proto.ProxyMapping{mapping}, + } + + s.SendServiceUpdate(update) + + msg1 := drainMapping(ch1) + msg2 := drainMapping(ch2) + require.NotNil(t, msg1) + require.NotNil(t, msg2) + + assert.NotEmpty(t, msg1.AuthToken) + assert.NotEmpty(t, msg2.AuthToken) + assert.NotEqual(t, msg1.AuthToken, msg2.AuthToken, "tokens must be unique per proxy") + + // Both tokens should validate + assert.NoError(t, tokenStore.ValidateAndConsume(msg1.AuthToken, "account-1", "service-1")) + assert.NoError(t, tokenStore.ValidateAndConsume(msg2.AuthToken, "account-1", "service-1")) +} + +// generateState creates a state using the same format as GetOIDCURL. +func generateState(s *ProxyServiceServer, redirectURL string) string { + nonce := make([]byte, 16) + _, _ = rand.Read(nonce) + nonceB64 := base64.URLEncoding.EncodeToString(nonce) + + payload := redirectURL + "|" + nonceB64 + hmacSum := s.generateHMAC(payload) + return base64.URLEncoding.EncodeToString([]byte(redirectURL)) + "|" + nonceB64 + "|" + hmacSum +} + +func TestOAuthState_NeverTheSame(t *testing.T) { + ctx := context.Background() + pkceStore, err := NewPKCEVerifierStore(ctx, 10*time.Minute, 10*time.Minute, 100) + require.NoError(t, err) + + s := &ProxyServiceServer{ + oidcConfig: ProxyOIDCConfig{ + HMACKey: []byte("test-hmac-key"), + }, + pkceVerifierStore: pkceStore, + } + + redirectURL := "https://app.example.com/callback" + + // Generate 100 states for the same redirect URL + states := make(map[string]bool) + for i := 0; i < 100; i++ { + state := generateState(s, redirectURL) + + // State must have 3 parts: base64(url)|nonce|hmac + parts := strings.Split(state, "|") + require.Equal(t, 3, len(parts), "state must have 3 parts") + + // State must be unique + require.False(t, states[state], "state %d is a duplicate", i) + states[state] = true + } +} + +func TestValidateState_RejectsOldTwoPartFormat(t *testing.T) { + ctx := context.Background() + pkceStore, err := NewPKCEVerifierStore(ctx, 10*time.Minute, 10*time.Minute, 100) + require.NoError(t, err) + + s := &ProxyServiceServer{ + oidcConfig: ProxyOIDCConfig{ + HMACKey: []byte("test-hmac-key"), + }, + pkceVerifierStore: pkceStore, + } + + // Old format had only 2 parts: base64(url)|hmac + err = s.pkceVerifierStore.Store("base64url|hmac", "test", 10*time.Minute) + require.NoError(t, err) + + _, _, err = s.ValidateState("base64url|hmac") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid state format") +} + +func TestValidateState_RejectsInvalidHMAC(t *testing.T) { + ctx := context.Background() + pkceStore, err := NewPKCEVerifierStore(ctx, 10*time.Minute, 10*time.Minute, 100) + require.NoError(t, err) + + s := &ProxyServiceServer{ + oidcConfig: ProxyOIDCConfig{ + HMACKey: []byte("test-hmac-key"), + }, + pkceVerifierStore: pkceStore, + } + + // Store with tampered HMAC + err = s.pkceVerifierStore.Store("dGVzdA==|nonce|wrong-hmac", "test", 10*time.Minute) + require.NoError(t, err) + + _, _, err = s.ValidateState("dGVzdA==|nonce|wrong-hmac") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid state signature") +} + +func TestSendServiceUpdateToCluster_FiltersOnCapability(t *testing.T) { + tokenStore, err := NewOneTimeTokenStore(context.Background(), time.Hour, 10*time.Minute, 100) + require.NoError(t, err) + + s := &ProxyServiceServer{ + tokenStore: tokenStore, + } + s.SetProxyController(newTestProxyController()) + + const cluster = "proxy.example.com" + + // Proxy A supports custom ports. + chA := registerFakeProxyWithCaps(s, "proxy-a", cluster, &proto.ProxyCapabilities{SupportsCustomPorts: ptr(true)}) + // Proxy B does NOT support custom ports (shared cloud proxy). + chB := registerFakeProxyWithCaps(s, "proxy-b", cluster, &proto.ProxyCapabilities{SupportsCustomPorts: ptr(false)}) + + ctx := context.Background() + + // TLS passthrough works on all proxies regardless of custom port support. + tlsMapping := &proto.ProxyMapping{ + Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, + Id: "service-tls", + AccountId: "account-1", + Domain: "db.example.com", + Mode: "tls", + ListenPort: 8443, + Path: []*proto.PathMapping{{Target: "10.0.0.5:5432"}}, + } + + s.SendServiceUpdateToCluster(ctx, tlsMapping, cluster) + + msgA := drainMapping(chA) + msgB := drainMapping(chB) + assert.NotNil(t, msgA, "proxy-a should receive TLS mapping") + assert.NotNil(t, msgB, "proxy-b should receive TLS mapping (passthrough works on all proxies)") + + // Send an HTTP mapping: both should receive it. + httpMapping := &proto.ProxyMapping{ + Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, + Id: "service-http", + AccountId: "account-1", + Domain: "app.example.com", + Path: []*proto.PathMapping{{Path: "/", Target: "http://10.0.0.1:80"}}, + } + + s.SendServiceUpdateToCluster(ctx, httpMapping, cluster) + + msgA = drainMapping(chA) + msgB = drainMapping(chB) + assert.NotNil(t, msgA, "proxy-a should receive HTTP mapping") + assert.NotNil(t, msgB, "proxy-b should receive HTTP mapping") +} + +func TestSendServiceUpdateToCluster_TLSNotFiltered(t *testing.T) { + tokenStore, err := NewOneTimeTokenStore(context.Background(), time.Hour, 10*time.Minute, 100) + require.NoError(t, err) + + s := &ProxyServiceServer{ + tokenStore: tokenStore, + } + s.SetProxyController(newTestProxyController()) + + const cluster = "proxy.example.com" + + chShared := registerFakeProxyWithCaps(s, "proxy-shared", cluster, &proto.ProxyCapabilities{SupportsCustomPorts: ptr(false)}) + + tlsMapping := &proto.ProxyMapping{ + Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, + Id: "service-tls", + AccountId: "account-1", + Domain: "db.example.com", + Mode: "tls", + Path: []*proto.PathMapping{{Target: "10.0.0.5:5432"}}, + } + + s.SendServiceUpdateToCluster(context.Background(), tlsMapping, cluster) + + msg := drainMapping(chShared) + assert.NotNil(t, msg, "shared proxy should receive TLS mapping even without custom port support") +} + +// TestServiceModifyNotifications exercises every possible modification +// scenario for an existing service, verifying the correct update types +// reach the correct clusters. +func TestServiceModifyNotifications(t *testing.T) { + tokenStore, err := NewOneTimeTokenStore(context.Background(), time.Hour, 10*time.Minute, 100) + require.NoError(t, err) + + newServer := func() (*ProxyServiceServer, map[string]chan *proto.GetMappingUpdateResponse) { + s := &ProxyServiceServer{ + tokenStore: tokenStore, + } + s.SetProxyController(newTestProxyController()) + chs := map[string]chan *proto.GetMappingUpdateResponse{ + "cluster-a": registerFakeProxyWithCaps(s, "proxy-a", "cluster-a", &proto.ProxyCapabilities{SupportsCustomPorts: ptr(true)}), + "cluster-b": registerFakeProxyWithCaps(s, "proxy-b", "cluster-b", &proto.ProxyCapabilities{SupportsCustomPorts: ptr(true)}), + } + return s, chs + } + + httpMapping := func(updateType proto.ProxyMappingUpdateType) *proto.ProxyMapping { + return &proto.ProxyMapping{ + Type: updateType, + Id: "svc-1", + AccountId: "acct-1", + Domain: "app.example.com", + Path: []*proto.PathMapping{{Path: "/", Target: "http://10.0.0.1:8080"}}, + } + } + + tlsOnlyMapping := func(updateType proto.ProxyMappingUpdateType) *proto.ProxyMapping { + return &proto.ProxyMapping{ + Type: updateType, + Id: "svc-1", + AccountId: "acct-1", + Domain: "app.example.com", + Mode: "tls", + ListenPort: 8443, + Path: []*proto.PathMapping{{Target: "10.0.0.1:443"}}, + } + } + + ctx := context.Background() + + t.Run("targets changed sends MODIFIED to same cluster", func(t *testing.T) { + s, chs := newServer() + s.SendServiceUpdateToCluster(ctx, httpMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED), "cluster-a") + + msg := drainMapping(chs["cluster-a"]) + require.NotNil(t, msg, "cluster-a should receive update") + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED, msg.Type) + assert.NotEmpty(t, msg.AuthToken, "MODIFIED should include token") + assert.True(t, drainEmpty(chs["cluster-b"]), "cluster-b should not receive update") + }) + + t.Run("auth config changed sends MODIFIED", func(t *testing.T) { + s, chs := newServer() + mapping := httpMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED) + mapping.Auth = &proto.Authentication{Password: true, Pin: true} + s.SendServiceUpdateToCluster(ctx, mapping, "cluster-a") + + msg := drainMapping(chs["cluster-a"]) + require.NotNil(t, msg) + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED, msg.Type) + assert.True(t, msg.Auth.Password) + assert.True(t, msg.Auth.Pin) + }) + + t.Run("HTTP to TLS transition sends MODIFIED with TLS config", func(t *testing.T) { + s, chs := newServer() + s.SendServiceUpdateToCluster(ctx, tlsOnlyMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED), "cluster-a") + + msg := drainMapping(chs["cluster-a"]) + require.NotNil(t, msg) + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED, msg.Type) + assert.Equal(t, "tls", msg.Mode, "mode should be tls") + assert.Equal(t, int32(8443), msg.ListenPort) + assert.Len(t, msg.Path, 1, "should have one path entry with target address") + assert.Equal(t, "10.0.0.1:443", msg.Path[0].Target) + }) + + t.Run("TLS to HTTP transition sends MODIFIED without TLS", func(t *testing.T) { + s, chs := newServer() + s.SendServiceUpdateToCluster(ctx, httpMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED), "cluster-a") + + msg := drainMapping(chs["cluster-a"]) + require.NotNil(t, msg) + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED, msg.Type) + assert.Empty(t, msg.Mode, "mode should be empty for HTTP") + assert.True(t, len(msg.Path) > 0) + }) + + t.Run("TLS port changed sends MODIFIED with new port", func(t *testing.T) { + s, chs := newServer() + mapping := tlsOnlyMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED) + mapping.ListenPort = 9443 + s.SendServiceUpdateToCluster(ctx, mapping, "cluster-a") + + msg := drainMapping(chs["cluster-a"]) + require.NotNil(t, msg) + assert.Equal(t, int32(9443), msg.ListenPort) + }) + + t.Run("disable sends REMOVED to cluster", func(t *testing.T) { + s, chs := newServer() + // Manager sends Delete when service is disabled + s.SendServiceUpdateToCluster(ctx, httpMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED), "cluster-a") + + msg := drainMapping(chs["cluster-a"]) + require.NotNil(t, msg) + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, msg.Type) + assert.Empty(t, msg.AuthToken, "DELETE should not have token") + }) + + t.Run("enable sends CREATED to cluster", func(t *testing.T) { + s, chs := newServer() + s.SendServiceUpdateToCluster(ctx, httpMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED), "cluster-a") + + msg := drainMapping(chs["cluster-a"]) + require.NotNil(t, msg) + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, msg.Type) + assert.NotEmpty(t, msg.AuthToken) + }) + + t.Run("domain change with cluster change sends DELETE to old CREATE to new", func(t *testing.T) { + s, chs := newServer() + // This is the pattern the manager produces: + // 1. DELETE on old cluster + s.SendServiceUpdateToCluster(ctx, httpMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED), "cluster-a") + // 2. CREATE on new cluster + s.SendServiceUpdateToCluster(ctx, httpMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED), "cluster-b") + + msgA := drainMapping(chs["cluster-a"]) + require.NotNil(t, msgA, "old cluster should receive DELETE") + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, msgA.Type) + + msgB := drainMapping(chs["cluster-b"]) + require.NotNil(t, msgB, "new cluster should receive CREATE") + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, msgB.Type) + assert.NotEmpty(t, msgB.AuthToken) + }) + + t.Run("domain change same cluster sends DELETE then CREATE", func(t *testing.T) { + s, chs := newServer() + // Domain changes within same cluster: manager sends DELETE (old domain) + CREATE (new domain). + s.SendServiceUpdateToCluster(ctx, httpMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED), "cluster-a") + s.SendServiceUpdateToCluster(ctx, httpMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED), "cluster-a") + + msgDel := drainMapping(chs["cluster-a"]) + require.NotNil(t, msgDel, "same cluster should receive DELETE") + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, msgDel.Type) + + msgCreate := drainMapping(chs["cluster-a"]) + require.NotNil(t, msgCreate, "same cluster should receive CREATE") + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, msgCreate.Type) + assert.NotEmpty(t, msgCreate.AuthToken) + }) + + t.Run("TLS passthrough sent to all proxies", func(t *testing.T) { + s := &ProxyServiceServer{ + tokenStore: tokenStore, + } + s.SetProxyController(newTestProxyController()) + const cluster = "proxy.example.com" + chModern := registerFakeProxyWithCaps(s, "modern", cluster, &proto.ProxyCapabilities{SupportsCustomPorts: ptr(true)}) + chLegacy := registerFakeProxyWithCaps(s, "legacy", cluster, &proto.ProxyCapabilities{SupportsCustomPorts: ptr(false)}) + + // TLS passthrough works on all proxies regardless of custom port support + s.SendServiceUpdateToCluster(ctx, tlsOnlyMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED), cluster) + + msgModern := drainMapping(chModern) + require.NotNil(t, msgModern, "modern proxy receives TLS update") + assert.Equal(t, "tls", msgModern.Mode) + + msgLegacy := drainMapping(chLegacy) + assert.NotNil(t, msgLegacy, "legacy proxy should also receive TLS passthrough") + }) + + t.Run("TLS on default port NOT filtered for legacy proxy", func(t *testing.T) { + s := &ProxyServiceServer{ + tokenStore: tokenStore, + } + s.SetProxyController(newTestProxyController()) + const cluster = "proxy.example.com" + chLegacy := registerFakeProxyWithCaps(s, "legacy", cluster, &proto.ProxyCapabilities{SupportsCustomPorts: ptr(false)}) + + mapping := tlsOnlyMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED) + mapping.ListenPort = 0 // default port + s.SendServiceUpdateToCluster(ctx, mapping, cluster) + + msgLegacy := drainMapping(chLegacy) + assert.NotNil(t, msgLegacy, "legacy proxy should receive TLS on default port") + }) + + t.Run("passthrough and rewrite flags propagated", func(t *testing.T) { + s, chs := newServer() + mapping := httpMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED) + mapping.PassHostHeader = true + mapping.RewriteRedirects = true + s.SendServiceUpdateToCluster(ctx, mapping, "cluster-a") + + msg := drainMapping(chs["cluster-a"]) + require.NotNil(t, msg) + assert.True(t, msg.PassHostHeader) + assert.True(t, msg.RewriteRedirects) + }) + + t.Run("multiple paths propagated in MODIFIED", func(t *testing.T) { + s, chs := newServer() + mapping := &proto.ProxyMapping{ + Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED, + Id: "svc-multi", + AccountId: "acct-1", + Domain: "multi.example.com", + Path: []*proto.PathMapping{ + {Path: "/", Target: "http://10.0.0.1:8080"}, + {Path: "/api", Target: "http://10.0.0.2:9090"}, + {Path: "/ws", Target: "http://10.0.0.3:3000"}, + }, + } + s.SendServiceUpdateToCluster(ctx, mapping, "cluster-a") + + msg := drainMapping(chs["cluster-a"]) + require.NotNil(t, msg) + require.Len(t, msg.Path, 3, "all paths should be present") + assert.Equal(t, "/", msg.Path[0].Path) + assert.Equal(t, "/api", msg.Path[1].Path) + assert.Equal(t, "/ws", msg.Path[2].Path) + }) +} diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go index 32049d044..6e8358f02 100644 --- a/management/internals/shared/grpc/server.go +++ b/management/internals/shared/grpc/server.go @@ -17,14 +17,16 @@ import ( pb "github.com/golang/protobuf/proto" // nolint "github.com/golang/protobuf/ptypes/timestamp" "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/realip" - "github.com/netbirdio/netbird/shared/management/client/common" log "github.com/sirupsen/logrus" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "google.golang.org/grpc/codes" "google.golang.org/grpc/peer" "google.golang.org/grpc/status" + "github.com/netbirdio/netbird/shared/management/client/common" + "github.com/netbirdio/netbird/management/internals/controllers/network_map" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" nbconfig "github.com/netbirdio/netbird/management/internals/server/config" "github.com/netbirdio/netbird/management/server/idp" "github.com/netbirdio/netbird/management/server/job" @@ -76,8 +78,12 @@ type Server struct { oAuthConfigProvider idp.OAuthConfigProvider - syncSem atomic.Int32 - syncLim int32 + syncSem atomic.Int32 + syncLimEnabled bool + syncLim int32 + + reverseProxyManager rpservice.Manager + reverseProxyMu sync.RWMutex } // NewServer creates a new Management server @@ -107,6 +113,7 @@ func NewServer( blockPeersWithSameConfig := strings.ToLower(os.Getenv(envBlockPeers)) == "true" syncLim := int32(defaultSyncLim) + syncLimEnabled := true if syncLimStr := os.Getenv(envConcurrentSyncs); syncLimStr != "" { syncLimParsed, err := strconv.Atoi(syncLimStr) if err != nil { @@ -114,6 +121,9 @@ func NewServer( } else { //nolint:gosec syncLim = int32(syncLimParsed) + if syncLim < 0 { + syncLimEnabled = false + } } } @@ -133,7 +143,8 @@ func NewServer( loginFilter: newLoginFilter(), - syncLim: syncLim, + syncLim: syncLim, + syncLimEnabled: syncLimEnabled, }, nil } @@ -211,12 +222,13 @@ func (s *Server) Job(srv proto.ManagementService_JobServer) error { // Sync validates the existence of a connecting peer, sends an initial state (all available for the connecting peers) and // notifies the connected peer of any updates (e.g. new peers under the same account) func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_SyncServer) error { - if s.syncSem.Load() >= s.syncLim { + if s.syncLimEnabled && s.syncSem.Load() >= s.syncLim { return status.Errorf(codes.ResourceExhausted, "too many concurrent sync requests, please try again later") } s.syncSem.Add(1) reqStart := time.Now() + syncStart := reqStart.UTC() ctx := srv.Context() @@ -293,7 +305,7 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S metahash := metaHash(peerMeta, realIP.String()) s.loginFilter.addLogin(peerKey.String(), metahash) - peer, netMap, postureChecks, dnsFwdPort, err := s.accountManager.SyncAndMarkPeer(ctx, accountID, peerKey.String(), peerMeta, realIP) + peer, netMap, postureChecks, dnsFwdPort, err := s.accountManager.SyncAndMarkPeer(ctx, accountID, peerKey.String(), peerMeta, realIP, syncStart) if err != nil { log.WithContext(ctx).Debugf("error while syncing peer %s: %v", peerKey.String(), err) s.syncSem.Add(-1) @@ -304,6 +316,7 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S if err != nil { log.WithContext(ctx).Debugf("error while sending initial sync for %s: %v", peerKey.String(), err) s.syncSem.Add(-1) + s.cancelPeerRoutinesWithoutLock(ctx, accountID, peer, syncStart) return err } @@ -311,24 +324,23 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S if err != nil { log.WithContext(ctx).Debugf("error while notify peer connected for %s: %v", peerKey.String(), err) s.syncSem.Add(-1) - s.cancelPeerRoutines(ctx, accountID, peer) + s.cancelPeerRoutinesWithoutLock(ctx, accountID, peer, syncStart) return err } s.secretsManager.SetupRefresh(ctx, accountID, peer.ID) - if s.appMetrics != nil { - s.appMetrics.GRPCMetrics().CountSyncRequestDuration(time.Since(reqStart), accountID) - } - unlock() unlock = nil + if s.appMetrics != nil { + s.appMetrics.GRPCMetrics().CountSyncRequestDuration(time.Since(reqStart), accountID) + } log.WithContext(ctx).Debugf("Sync took %s", time.Since(reqStart)) s.syncSem.Add(-1) - return s.handleUpdates(ctx, accountID, peerKey, peer, updates, srv) + return s.handleUpdates(ctx, accountID, peerKey, peer, updates, srv, syncStart) } func (s *Server) handleHandshake(ctx context.Context, srv proto.ManagementService_JobServer) (wgtypes.Key, error) { @@ -396,11 +408,20 @@ func (s *Server) sendJobsLoop(ctx context.Context, accountID string, peerKey wgt } // handleUpdates sends updates to the connected peer until the updates channel is closed. -func (s *Server) handleUpdates(ctx context.Context, accountID string, peerKey wgtypes.Key, peer *nbpeer.Peer, updates chan *network_map.UpdateMessage, srv proto.ManagementService_SyncServer) error { +// It implements a backpressure mechanism that sends the first update immediately, +// then debounces subsequent rapid updates, ensuring only the latest update is sent +// after a quiet period. +func (s *Server) handleUpdates(ctx context.Context, accountID string, peerKey wgtypes.Key, peer *nbpeer.Peer, updates chan *network_map.UpdateMessage, srv proto.ManagementService_SyncServer, streamStartTime time.Time) error { log.WithContext(ctx).Tracef("starting to handle updates for peer %s", peerKey.String()) + + // Create a debouncer for this peer connection + debouncer := NewUpdateDebouncer(1000 * time.Millisecond) + defer debouncer.Stop() + for { select { // condition when there are some updates + // todo set the updates channel size to 1 case update, open := <-updates: if s.appMetrics != nil { s.appMetrics.GRPCMetrics().UpdateChannelQueueLength(len(updates) + 1) @@ -408,20 +429,38 @@ func (s *Server) handleUpdates(ctx context.Context, accountID string, peerKey wg if !open { log.WithContext(ctx).Debugf("updates channel for peer %s was closed", peerKey.String()) - s.cancelPeerRoutines(ctx, accountID, peer) + s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime) return nil } + log.WithContext(ctx).Debugf("received an update for peer %s", peerKey.String()) - if err := s.sendUpdate(ctx, accountID, peerKey, peer, update, srv); err != nil { - log.WithContext(ctx).Debugf("error while sending an update to peer %s: %v", peerKey.String(), err) - return err + if debouncer.ProcessUpdate(update) { + // Send immediately (first update or after quiet period) + if err := s.sendUpdate(ctx, accountID, peerKey, peer, update, srv, streamStartTime); err != nil { + log.WithContext(ctx).Debugf("error while sending an update to peer %s: %v", peerKey.String(), err) + return err + } + } + + // Timer expired - quiet period reached, send pending updates if any + case <-debouncer.TimerChannel(): + pendingUpdates := debouncer.GetPendingUpdates() + if len(pendingUpdates) == 0 { + continue + } + log.WithContext(ctx).Debugf("sending %d debounced update(s) for peer %s", len(pendingUpdates), peerKey.String()) + for _, pendingUpdate := range pendingUpdates { + if err := s.sendUpdate(ctx, accountID, peerKey, peer, pendingUpdate, srv, streamStartTime); err != nil { + log.WithContext(ctx).Debugf("error while sending an update to peer %s: %v", peerKey.String(), err) + return err + } } // condition when client <-> server connection has been terminated case <-srv.Context().Done(): // happens when connection drops, e.g. client disconnects log.WithContext(ctx).Debugf("stream of peer %s has been closed", peerKey.String()) - s.cancelPeerRoutines(ctx, accountID, peer) + s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime) return srv.Context().Err() } } @@ -429,16 +468,16 @@ func (s *Server) handleUpdates(ctx context.Context, accountID string, peerKey wg // sendUpdate encrypts the update message using the peer key and the server's wireguard key, // then sends the encrypted message to the connected peer via the sync server. -func (s *Server) sendUpdate(ctx context.Context, accountID string, peerKey wgtypes.Key, peer *nbpeer.Peer, update *network_map.UpdateMessage, srv proto.ManagementService_SyncServer) error { +func (s *Server) sendUpdate(ctx context.Context, accountID string, peerKey wgtypes.Key, peer *nbpeer.Peer, update *network_map.UpdateMessage, srv proto.ManagementService_SyncServer, streamStartTime time.Time) error { key, err := s.secretsManager.GetWGKey() if err != nil { - s.cancelPeerRoutines(ctx, accountID, peer) + s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime) return status.Errorf(codes.Internal, "failed processing update message") } encryptedResp, err := encryption.EncryptMessage(peerKey, key, update.Update) if err != nil { - s.cancelPeerRoutines(ctx, accountID, peer) + s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime) return status.Errorf(codes.Internal, "failed processing update message") } err = srv.Send(&proto.EncryptedMessage{ @@ -446,7 +485,7 @@ func (s *Server) sendUpdate(ctx context.Context, accountID string, peerKey wgtyp Body: encryptedResp, }) if err != nil { - s.cancelPeerRoutines(ctx, accountID, peer) + s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime) return status.Errorf(codes.Internal, "failed sending update message") } log.WithContext(ctx).Debugf("sent an update to peer %s", peerKey.String()) @@ -478,11 +517,15 @@ func (s *Server) sendJob(ctx context.Context, peerKey wgtypes.Key, job *job.Even return nil } -func (s *Server) cancelPeerRoutines(ctx context.Context, accountID string, peer *nbpeer.Peer) { +func (s *Server) cancelPeerRoutines(ctx context.Context, accountID string, peer *nbpeer.Peer, streamStartTime time.Time) { unlock := s.acquirePeerLockByUID(ctx, peer.Key) defer unlock() - err := s.accountManager.OnPeerDisconnected(ctx, accountID, peer.Key) + s.cancelPeerRoutinesWithoutLock(ctx, accountID, peer, streamStartTime) +} + +func (s *Server) cancelPeerRoutinesWithoutLock(ctx context.Context, accountID string, peer *nbpeer.Peer, streamStartTime time.Time) { + err := s.accountManager.OnPeerDisconnected(ctx, accountID, peer.Key, streamStartTime) if err != nil { log.WithContext(ctx).Errorf("failed to disconnect peer %s properly: %v", peer.Key, err) } @@ -699,13 +742,6 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto log.WithContext(ctx).Debugf("Login request from peer [%s] [%s]", req.WgPubKey, sRealIP) - defer func() { - if s.appMetrics != nil { - s.appMetrics.GRPCMetrics().CountLoginRequestDuration(time.Since(reqStart), accountID) - } - log.WithContext(ctx).Debugf("Login took %s", time.Since(reqStart)) - }() - if loginReq.GetMeta() == nil { msg := status.Errorf(codes.FailedPrecondition, "peer system meta has to be provided to log in. Peer %s, remote addr %s", peerKey.String(), realIP) @@ -755,6 +791,11 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto return nil, status.Errorf(codes.Internal, "failed logging in peer") } + if s.appMetrics != nil { + s.appMetrics.GRPCMetrics().CountLoginRequestDuration(time.Since(reqStart), accountID) + } + log.WithContext(ctx).Debugf("Login took %s", time.Since(reqStart)) + return &proto.EncryptedMessage{ WgPubKey: key.PublicKey().String(), Body: encryptedResp, diff --git a/management/internals/shared/grpc/token_mgr.go b/management/internals/shared/grpc/token_mgr.go index ccb32202f..65e58ad41 100644 --- a/management/internals/shared/grpc/token_mgr.go +++ b/management/internals/shared/grpc/token_mgr.go @@ -242,7 +242,10 @@ func (m *TimeBasedAuthSecretsManager) pushNewTURNAndRelayTokens(ctx context.Cont m.extendNetbirdConfig(ctx, peerID, accountID, update) log.WithContext(ctx).Debugf("sending new TURN credentials to peer %s", peerID) - m.updateManager.SendUpdate(ctx, peerID, &network_map.UpdateMessage{Update: update}) + m.updateManager.SendUpdate(ctx, peerID, &network_map.UpdateMessage{ + Update: update, + MessageType: network_map.MessageTypeControlConfig, + }) } func (m *TimeBasedAuthSecretsManager) pushNewRelayTokens(ctx context.Context, accountID, peerID string) { @@ -266,7 +269,10 @@ func (m *TimeBasedAuthSecretsManager) pushNewRelayTokens(ctx context.Context, ac m.extendNetbirdConfig(ctx, peerID, accountID, update) log.WithContext(ctx).Debugf("sending new relay credentials to peer %s", peerID) - m.updateManager.SendUpdate(ctx, peerID, &network_map.UpdateMessage{Update: update}) + m.updateManager.SendUpdate(ctx, peerID, &network_map.UpdateMessage{ + Update: update, + MessageType: network_map.MessageTypeControlConfig, + }) } func (m *TimeBasedAuthSecretsManager) extendNetbirdConfig(ctx context.Context, peerID, accountID string, update *proto.SyncResponse) { diff --git a/management/internals/shared/grpc/update_debouncer.go b/management/internals/shared/grpc/update_debouncer.go new file mode 100644 index 000000000..8af9c2656 --- /dev/null +++ b/management/internals/shared/grpc/update_debouncer.go @@ -0,0 +1,103 @@ +package grpc + +import ( + "time" + + "github.com/netbirdio/netbird/management/internals/controllers/network_map" +) + +// UpdateDebouncer implements a backpressure mechanism that: +// - Sends the first update immediately +// - Coalesces rapid subsequent network map updates (only latest matters) +// - Queues control/config updates (all must be delivered) +// - Preserves the order of messages (important for control configs between network maps) +// - Ensures pending updates are sent after a quiet period +type UpdateDebouncer struct { + debounceInterval time.Duration + timer *time.Timer + pendingUpdates []*network_map.UpdateMessage // Queue that preserves order + timerC <-chan time.Time +} + +// NewUpdateDebouncer creates a new debouncer with the specified interval +func NewUpdateDebouncer(interval time.Duration) *UpdateDebouncer { + return &UpdateDebouncer{ + debounceInterval: interval, + } +} + +// ProcessUpdate handles an incoming update and returns whether it should be sent immediately +func (d *UpdateDebouncer) ProcessUpdate(update *network_map.UpdateMessage) bool { + if d.timer == nil { + // No active debounce timer, signal to send immediately + // and start the debounce period + d.startTimer() + return true + } + + // Already in debounce period, accumulate this update preserving order + // Check if we should coalesce with the last pending update + if len(d.pendingUpdates) > 0 && + update.MessageType == network_map.MessageTypeNetworkMap && + d.pendingUpdates[len(d.pendingUpdates)-1].MessageType == network_map.MessageTypeNetworkMap { + // Replace the last network map with this one (coalesce consecutive network maps) + d.pendingUpdates[len(d.pendingUpdates)-1] = update + } else { + // Append to the queue (preserves order for control configs and non-consecutive network maps) + d.pendingUpdates = append(d.pendingUpdates, update) + } + d.resetTimer() + return false +} + +// TimerChannel returns the timer channel for select statements +func (d *UpdateDebouncer) TimerChannel() <-chan time.Time { + if d.timer == nil { + return nil + } + return d.timerC +} + +// GetPendingUpdates returns and clears all pending updates after timer expiration. +// Updates are returned in the order they were received, with consecutive network maps +// already coalesced to only the latest one. +// If there were pending updates, it restarts the timer to continue debouncing. +// If there were no pending updates, it clears the timer (true quiet period). +func (d *UpdateDebouncer) GetPendingUpdates() []*network_map.UpdateMessage { + updates := d.pendingUpdates + d.pendingUpdates = nil + + if len(updates) > 0 { + // There were pending updates, so updates are still coming rapidly + // Restart the timer to continue debouncing mode + if d.timer != nil { + d.timer.Reset(d.debounceInterval) + } + } else { + // No pending updates means true quiet period - return to immediate mode + d.timer = nil + d.timerC = nil + } + + return updates +} + +// Stop stops the debouncer and cleans up resources +func (d *UpdateDebouncer) Stop() { + if d.timer != nil { + d.timer.Stop() + d.timer = nil + d.timerC = nil + } + d.pendingUpdates = nil +} + +func (d *UpdateDebouncer) startTimer() { + d.timer = time.NewTimer(d.debounceInterval) + d.timerC = d.timer.C +} + +func (d *UpdateDebouncer) resetTimer() { + d.timer.Stop() + d.timer.Reset(d.debounceInterval) +} diff --git a/management/internals/shared/grpc/update_debouncer_test.go b/management/internals/shared/grpc/update_debouncer_test.go new file mode 100644 index 000000000..075994a2d --- /dev/null +++ b/management/internals/shared/grpc/update_debouncer_test.go @@ -0,0 +1,587 @@ +package grpc + +import ( + "testing" + "time" + + "github.com/netbirdio/netbird/management/internals/controllers/network_map" + "github.com/netbirdio/netbird/shared/management/proto" +) + +func TestUpdateDebouncer_FirstUpdateSentImmediately(t *testing.T) { + debouncer := NewUpdateDebouncer(50 * time.Millisecond) + defer debouncer.Stop() + + update := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{}, + MessageType: network_map.MessageTypeNetworkMap, + } + + shouldSend := debouncer.ProcessUpdate(update) + + if !shouldSend { + t.Error("First update should be sent immediately") + } + + if debouncer.TimerChannel() == nil { + t.Error("Timer should be started after first update") + } +} + +func TestUpdateDebouncer_RapidUpdatesCoalesced(t *testing.T) { + debouncer := NewUpdateDebouncer(50 * time.Millisecond) + defer debouncer.Stop() + + update1 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{}, + MessageType: network_map.MessageTypeNetworkMap, + } + update2 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{}, + MessageType: network_map.MessageTypeNetworkMap, + } + update3 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{}, + MessageType: network_map.MessageTypeNetworkMap, + } + + // First update should be sent immediately + if !debouncer.ProcessUpdate(update1) { + t.Error("First update should be sent immediately") + } + + // Rapid subsequent updates should be coalesced + if debouncer.ProcessUpdate(update2) { + t.Error("Second rapid update should not be sent immediately") + } + + if debouncer.ProcessUpdate(update3) { + t.Error("Third rapid update should not be sent immediately") + } + + // Wait for debounce period + select { + case <-debouncer.TimerChannel(): + pendingUpdates := debouncer.GetPendingUpdates() + if len(pendingUpdates) != 1 { + t.Errorf("Should get exactly 1 pending update, got %d", len(pendingUpdates)) + } + if pendingUpdates[0] != update3 { + t.Error("Should get the last update (update3)") + } + case <-time.After(100 * time.Millisecond): + t.Error("Timer should have fired") + } +} + +func TestUpdateDebouncer_LastUpdateAlwaysSent(t *testing.T) { + debouncer := NewUpdateDebouncer(30 * time.Millisecond) + defer debouncer.Stop() + + update1 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{}, + MessageType: network_map.MessageTypeNetworkMap, + } + update2 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{}, + MessageType: network_map.MessageTypeNetworkMap, + } + + // Send first update + debouncer.ProcessUpdate(update1) + + // Send second update within debounce period + debouncer.ProcessUpdate(update2) + + // Wait for timer + select { + case <-debouncer.TimerChannel(): + pendingUpdates := debouncer.GetPendingUpdates() + if len(pendingUpdates) != 1 { + t.Errorf("Should get exactly 1 pending update, got %d", len(pendingUpdates)) + } + if pendingUpdates[0] != update2 { + t.Error("Should get the last update") + } + if pendingUpdates[0] == update1 { + t.Error("Should not get the first update") + } + case <-time.After(100 * time.Millisecond): + t.Error("Timer should have fired") + } +} + +func TestUpdateDebouncer_TimerResetOnNewUpdate(t *testing.T) { + debouncer := NewUpdateDebouncer(50 * time.Millisecond) + defer debouncer.Stop() + + update1 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{}, + MessageType: network_map.MessageTypeNetworkMap, + } + update2 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{}, + MessageType: network_map.MessageTypeNetworkMap, + } + update3 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{}, + MessageType: network_map.MessageTypeNetworkMap, + } + + // Send first update + debouncer.ProcessUpdate(update1) + + // Wait a bit, but not the full debounce period + time.Sleep(30 * time.Millisecond) + + // Send second update - should reset timer + debouncer.ProcessUpdate(update2) + + // Wait a bit more + time.Sleep(30 * time.Millisecond) + + // Send third update - should reset timer again + debouncer.ProcessUpdate(update3) + + // Now wait for the timer (should fire after last update's reset) + select { + case <-debouncer.TimerChannel(): + pendingUpdates := debouncer.GetPendingUpdates() + if len(pendingUpdates) != 1 { + t.Errorf("Should get exactly 1 pending update, got %d", len(pendingUpdates)) + } + if pendingUpdates[0] != update3 { + t.Error("Should get the last update (update3)") + } + // Timer should be restarted since there was a pending update + if debouncer.TimerChannel() == nil { + t.Error("Timer should be restarted after sending pending update") + } + case <-time.After(150 * time.Millisecond): + t.Error("Timer should have fired") + } +} + +func TestUpdateDebouncer_TimerRestartsAfterPendingUpdateSent(t *testing.T) { + debouncer := NewUpdateDebouncer(30 * time.Millisecond) + defer debouncer.Stop() + + update1 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{}, + MessageType: network_map.MessageTypeNetworkMap, + } + update2 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{}, + MessageType: network_map.MessageTypeNetworkMap, + } + update3 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{}, + MessageType: network_map.MessageTypeNetworkMap, + } + + // First update sent immediately + debouncer.ProcessUpdate(update1) + + // Second update coalesced + debouncer.ProcessUpdate(update2) + + // Wait for timer to expire + select { + case <-debouncer.TimerChannel(): + pendingUpdates := debouncer.GetPendingUpdates() + + if len(pendingUpdates) == 0 { + t.Fatal("Should have pending update") + } + + // After sending pending update, timer is restarted, so next update is NOT immediate + if debouncer.ProcessUpdate(update3) { + t.Error("Update after debounced send should not be sent immediately (timer restarted)") + } + + // Wait for the restarted timer and verify update3 is pending + select { + case <-debouncer.TimerChannel(): + finalUpdates := debouncer.GetPendingUpdates() + if len(finalUpdates) != 1 || finalUpdates[0] != update3 { + t.Error("Should get update3 as pending") + } + case <-time.After(100 * time.Millisecond): + t.Error("Timer should have fired for restarted timer") + } + case <-time.After(100 * time.Millisecond): + t.Error("Timer should have fired") + } +} + +func TestUpdateDebouncer_StopCleansUp(t *testing.T) { + debouncer := NewUpdateDebouncer(50 * time.Millisecond) + + update := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{}, + MessageType: network_map.MessageTypeNetworkMap, + } + + // Send update to start timer + debouncer.ProcessUpdate(update) + + // Stop should clean up + debouncer.Stop() + + // Multiple stops should be safe + debouncer.Stop() +} + +func TestUpdateDebouncer_HighFrequencyUpdates(t *testing.T) { + debouncer := NewUpdateDebouncer(50 * time.Millisecond) + defer debouncer.Stop() + + // Simulate high-frequency updates + var lastUpdate *network_map.UpdateMessage + sentImmediately := 0 + for i := 0; i < 100; i++ { + update := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{ + NetworkMap: &proto.NetworkMap{ + Serial: uint64(i), + }, + }, + MessageType: network_map.MessageTypeNetworkMap, + } + lastUpdate = update + if debouncer.ProcessUpdate(update) { + sentImmediately++ + } + time.Sleep(1 * time.Millisecond) // Very rapid updates + } + + // Only first update should be sent immediately + if sentImmediately != 1 { + t.Errorf("Expected only 1 update sent immediately, got %d", sentImmediately) + } + + // Wait for debounce period + select { + case <-debouncer.TimerChannel(): + pendingUpdates := debouncer.GetPendingUpdates() + if len(pendingUpdates) != 1 { + t.Errorf("Should get exactly 1 pending update, got %d", len(pendingUpdates)) + } + if pendingUpdates[0] != lastUpdate { + t.Error("Should get the very last update") + } + if pendingUpdates[0].Update.NetworkMap.Serial != 99 { + t.Errorf("Expected serial 99, got %d", pendingUpdates[0].Update.NetworkMap.Serial) + } + case <-time.After(200 * time.Millisecond): + t.Error("Timer should have fired") + } +} + +func TestUpdateDebouncer_NoUpdatesAfterFirst(t *testing.T) { + debouncer := NewUpdateDebouncer(30 * time.Millisecond) + defer debouncer.Stop() + + update := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{}, + MessageType: network_map.MessageTypeNetworkMap, + } + + // Send first update + if !debouncer.ProcessUpdate(update) { + t.Error("First update should be sent immediately") + } + + // Wait for timer to expire with no additional updates (true quiet period) + select { + case <-debouncer.TimerChannel(): + pendingUpdates := debouncer.GetPendingUpdates() + if len(pendingUpdates) != 0 { + t.Error("Should have no pending updates") + } + // After true quiet period, timer should be cleared + if debouncer.TimerChannel() != nil { + t.Error("Timer should be cleared after quiet period") + } + case <-time.After(100 * time.Millisecond): + t.Error("Timer should have fired") + } +} + +func TestUpdateDebouncer_IntermediateUpdatesDropped(t *testing.T) { + debouncer := NewUpdateDebouncer(50 * time.Millisecond) + defer debouncer.Stop() + + updates := make([]*network_map.UpdateMessage, 5) + for i := range updates { + updates[i] = &network_map.UpdateMessage{ + Update: &proto.SyncResponse{ + NetworkMap: &proto.NetworkMap{ + Serial: uint64(i), + }, + }, + MessageType: network_map.MessageTypeNetworkMap, + } + } + + // First update sent immediately + debouncer.ProcessUpdate(updates[0]) + + // Send updates 1, 2, 3, 4 rapidly - only last one should remain pending + debouncer.ProcessUpdate(updates[1]) + debouncer.ProcessUpdate(updates[2]) + debouncer.ProcessUpdate(updates[3]) + debouncer.ProcessUpdate(updates[4]) + + // Wait for debounce + <-debouncer.TimerChannel() + pendingUpdates := debouncer.GetPendingUpdates() + + if len(pendingUpdates) != 1 { + t.Errorf("Should get exactly 1 pending update, got %d", len(pendingUpdates)) + } + if pendingUpdates[0].Update.NetworkMap.Serial != 4 { + t.Errorf("Expected only the last update (serial 4), got serial %d", pendingUpdates[0].Update.NetworkMap.Serial) + } +} + +func TestUpdateDebouncer_TrueQuietPeriodResetsToImmediateMode(t *testing.T) { + debouncer := NewUpdateDebouncer(30 * time.Millisecond) + defer debouncer.Stop() + + update1 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{}, + MessageType: network_map.MessageTypeNetworkMap, + } + update2 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{}, + MessageType: network_map.MessageTypeNetworkMap, + } + + // First update sent immediately + if !debouncer.ProcessUpdate(update1) { + t.Error("First update should be sent immediately") + } + + // Wait for timer without sending any more updates (true quiet period) + <-debouncer.TimerChannel() + pendingUpdates := debouncer.GetPendingUpdates() + + if len(pendingUpdates) != 0 { + t.Error("Should have no pending updates during quiet period") + } + + // After true quiet period, next update should be sent immediately + if !debouncer.ProcessUpdate(update2) { + t.Error("Update after true quiet period should be sent immediately") + } +} + +func TestUpdateDebouncer_ContinuousHighFrequencyStaysInDebounceMode(t *testing.T) { + debouncer := NewUpdateDebouncer(50 * time.Millisecond) + defer debouncer.Stop() + + // Simulate continuous high-frequency updates + for i := 0; i < 10; i++ { + update := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{ + NetworkMap: &proto.NetworkMap{ + Serial: uint64(i), + }, + }, + MessageType: network_map.MessageTypeNetworkMap, + } + + if i == 0 { + // First one sent immediately + if !debouncer.ProcessUpdate(update) { + t.Error("First update should be sent immediately") + } + } else { + // All others should be coalesced (not sent immediately) + if debouncer.ProcessUpdate(update) { + t.Errorf("Update %d should not be sent immediately", i) + } + } + + // Wait a bit but send next update before debounce expires + time.Sleep(20 * time.Millisecond) + } + + // Now wait for final debounce + select { + case <-debouncer.TimerChannel(): + pendingUpdates := debouncer.GetPendingUpdates() + if len(pendingUpdates) == 0 { + t.Fatal("Should have the last update pending") + } + if pendingUpdates[0].Update.NetworkMap.Serial != 9 { + t.Errorf("Expected serial 9, got %d", pendingUpdates[0].Update.NetworkMap.Serial) + } + case <-time.After(200 * time.Millisecond): + t.Error("Timer should have fired") + } +} + +func TestUpdateDebouncer_ControlConfigMessagesQueued(t *testing.T) { + debouncer := NewUpdateDebouncer(50 * time.Millisecond) + defer debouncer.Stop() + + netmapUpdate := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{NetworkMap: &proto.NetworkMap{Serial: 1}}, + MessageType: network_map.MessageTypeNetworkMap, + } + tokenUpdate1 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{NetbirdConfig: &proto.NetbirdConfig{}}, + MessageType: network_map.MessageTypeControlConfig, + } + tokenUpdate2 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{NetbirdConfig: &proto.NetbirdConfig{}}, + MessageType: network_map.MessageTypeControlConfig, + } + + // First update sent immediately + debouncer.ProcessUpdate(netmapUpdate) + + // Send multiple control config updates - they should all be queued + debouncer.ProcessUpdate(tokenUpdate1) + debouncer.ProcessUpdate(tokenUpdate2) + + // Wait for debounce period + select { + case <-debouncer.TimerChannel(): + pendingUpdates := debouncer.GetPendingUpdates() + // Should get both control config updates + if len(pendingUpdates) != 2 { + t.Errorf("Expected 2 control config updates, got %d", len(pendingUpdates)) + } + // Control configs should come first + if pendingUpdates[0] != tokenUpdate1 { + t.Error("First pending update should be tokenUpdate1") + } + if pendingUpdates[1] != tokenUpdate2 { + t.Error("Second pending update should be tokenUpdate2") + } + case <-time.After(200 * time.Millisecond): + t.Error("Timer should have fired") + } +} + +func TestUpdateDebouncer_MixedMessageTypes(t *testing.T) { + debouncer := NewUpdateDebouncer(50 * time.Millisecond) + defer debouncer.Stop() + + netmapUpdate1 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{NetworkMap: &proto.NetworkMap{Serial: 1}}, + MessageType: network_map.MessageTypeNetworkMap, + } + netmapUpdate2 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{NetworkMap: &proto.NetworkMap{Serial: 2}}, + MessageType: network_map.MessageTypeNetworkMap, + } + tokenUpdate := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{NetbirdConfig: &proto.NetbirdConfig{}}, + MessageType: network_map.MessageTypeControlConfig, + } + + // First update sent immediately + debouncer.ProcessUpdate(netmapUpdate1) + + // Send token update and network map update + debouncer.ProcessUpdate(tokenUpdate) + debouncer.ProcessUpdate(netmapUpdate2) + + // Wait for debounce period + select { + case <-debouncer.TimerChannel(): + pendingUpdates := debouncer.GetPendingUpdates() + // Should get 2 updates in order: token, then network map + if len(pendingUpdates) != 2 { + t.Errorf("Expected 2 pending updates, got %d", len(pendingUpdates)) + } + // Token update should come first (preserves order) + if pendingUpdates[0] != tokenUpdate { + t.Error("First pending update should be tokenUpdate") + } + // Network map update should come second + if pendingUpdates[1] != netmapUpdate2 { + t.Error("Second pending update should be netmapUpdate2") + } + case <-time.After(200 * time.Millisecond): + t.Error("Timer should have fired") + } +} + +func TestUpdateDebouncer_OrderPreservation(t *testing.T) { + debouncer := NewUpdateDebouncer(50 * time.Millisecond) + defer debouncer.Stop() + + // Simulate: 50 network maps -> 1 control config -> 50 network maps + // Expected result: 3 messages (netmap, controlConfig, netmap) + + // Send first network map immediately + firstNetmap := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{NetworkMap: &proto.NetworkMap{Serial: 0}}, + MessageType: network_map.MessageTypeNetworkMap, + } + if !debouncer.ProcessUpdate(firstNetmap) { + t.Error("First update should be sent immediately") + } + + // Send 49 more network maps (will be coalesced to last one) + var lastNetmapBatch1 *network_map.UpdateMessage + for i := 1; i < 50; i++ { + lastNetmapBatch1 = &network_map.UpdateMessage{ + Update: &proto.SyncResponse{NetworkMap: &proto.NetworkMap{Serial: uint64(i)}}, + MessageType: network_map.MessageTypeNetworkMap, + } + debouncer.ProcessUpdate(lastNetmapBatch1) + } + + // Send 1 control config + controlConfig := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{NetbirdConfig: &proto.NetbirdConfig{}}, + MessageType: network_map.MessageTypeControlConfig, + } + debouncer.ProcessUpdate(controlConfig) + + // Send 50 more network maps (will be coalesced to last one) + var lastNetmapBatch2 *network_map.UpdateMessage + for i := 50; i < 100; i++ { + lastNetmapBatch2 = &network_map.UpdateMessage{ + Update: &proto.SyncResponse{NetworkMap: &proto.NetworkMap{Serial: uint64(i)}}, + MessageType: network_map.MessageTypeNetworkMap, + } + debouncer.ProcessUpdate(lastNetmapBatch2) + } + + // Wait for debounce period + select { + case <-debouncer.TimerChannel(): + pendingUpdates := debouncer.GetPendingUpdates() + // Should get exactly 3 updates: netmap, controlConfig, netmap + if len(pendingUpdates) != 3 { + t.Errorf("Expected 3 pending updates, got %d", len(pendingUpdates)) + } + // First should be the last netmap from batch 1 + if pendingUpdates[0] != lastNetmapBatch1 { + t.Error("First pending update should be last netmap from batch 1") + } + if pendingUpdates[0].Update.NetworkMap.Serial != 49 { + t.Errorf("Expected serial 49, got %d", pendingUpdates[0].Update.NetworkMap.Serial) + } + // Second should be the control config + if pendingUpdates[1] != controlConfig { + t.Error("Second pending update should be control config") + } + // Third should be the last netmap from batch 2 + if pendingUpdates[2] != lastNetmapBatch2 { + t.Error("Third pending update should be last netmap from batch 2") + } + if pendingUpdates[2].Update.NetworkMap.Serial != 99 { + t.Errorf("Expected serial 99, got %d", pendingUpdates[2].Update.NetworkMap.Serial) + } + case <-time.After(200 * time.Millisecond): + t.Error("Timer should have fired") + } +} diff --git a/management/internals/shared/grpc/validate_session_test.go b/management/internals/shared/grpc/validate_session_test.go new file mode 100644 index 000000000..647e8443b --- /dev/null +++ b/management/internals/shared/grpc/validate_session_test.go @@ -0,0 +1,351 @@ +//go:build integration + +package grpc + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/proxy/auth" + "github.com/netbirdio/netbird/shared/management/proto" +) + +type validateSessionTestSetup struct { + proxyService *ProxyServiceServer + store store.Store + cleanup func() +} + +func setupValidateSessionTest(t *testing.T) *validateSessionTestSetup { + t.Helper() + + ctx := context.Background() + testStore, storeCleanup, err := store.NewTestStoreFromSQL(ctx, "../../../server/testdata/auth_callback.sql", t.TempDir()) + require.NoError(t, err) + + serviceManager := &testValidateSessionServiceManager{store: testStore} + usersManager := &testValidateSessionUsersManager{store: testStore} + proxyManager := &testValidateSessionProxyManager{} + + tokenStore, err := NewOneTimeTokenStore(ctx, time.Minute, 10*time.Minute, 100) + require.NoError(t, err) + + pkceStore, err := NewPKCEVerifierStore(ctx, 10*time.Minute, 10*time.Minute, 100) + require.NoError(t, err) + + proxyService := NewProxyServiceServer(nil, tokenStore, pkceStore, ProxyOIDCConfig{}, nil, usersManager, proxyManager) + proxyService.SetServiceManager(serviceManager) + + createTestProxies(t, ctx, testStore) + + return &validateSessionTestSetup{ + proxyService: proxyService, + store: testStore, + cleanup: storeCleanup, + } +} + +func createTestProxies(t *testing.T, ctx context.Context, testStore store.Store) { + t.Helper() + + pubKey, privKey := generateSessionKeyPair(t) + + testProxy := &service.Service{ + ID: "testProxyId", + AccountID: "testAccountId", + Name: "Test Proxy", + Domain: "test-proxy.example.com", + Enabled: true, + SessionPrivateKey: privKey, + SessionPublicKey: pubKey, + Auth: service.AuthConfig{ + BearerAuth: &service.BearerAuthConfig{ + Enabled: true, + }, + }, + } + require.NoError(t, testStore.CreateService(ctx, testProxy)) + + restrictedProxy := &service.Service{ + ID: "restrictedProxyId", + AccountID: "testAccountId", + Name: "Restricted Proxy", + Domain: "restricted-proxy.example.com", + Enabled: true, + SessionPrivateKey: privKey, + SessionPublicKey: pubKey, + Auth: service.AuthConfig{ + BearerAuth: &service.BearerAuthConfig{ + Enabled: true, + DistributionGroups: []string{"allowedGroupId"}, + }, + }, + } + require.NoError(t, testStore.CreateService(ctx, restrictedProxy)) +} + +func generateSessionKeyPair(t *testing.T) (string, string) { + t.Helper() + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + return base64.StdEncoding.EncodeToString(pub), base64.StdEncoding.EncodeToString(priv) +} + +func createSessionToken(t *testing.T, privKeyB64, userID, domain string) string { + t.Helper() + token, err := sessionkey.SignToken(privKeyB64, userID, domain, auth.MethodOIDC, time.Hour) + require.NoError(t, err) + return token +} + +func TestValidateSession_UserAllowed(t *testing.T) { + setup := setupValidateSessionTest(t) + defer setup.cleanup() + + proxy, err := setup.store.GetServiceByID(context.Background(), store.LockingStrengthNone, "testAccountId", "testProxyId") + require.NoError(t, err) + + token := createSessionToken(t, proxy.SessionPrivateKey, "allowedUserId", "test-proxy.example.com") + + resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + Domain: "test-proxy.example.com", + SessionToken: token, + }) + + require.NoError(t, err) + assert.True(t, resp.Valid, "User should be allowed access") + assert.Equal(t, "allowedUserId", resp.UserId) + assert.Empty(t, resp.DeniedReason) +} + +func TestValidateSession_UserNotInAllowedGroup(t *testing.T) { + setup := setupValidateSessionTest(t) + defer setup.cleanup() + + proxy, err := setup.store.GetServiceByID(context.Background(), store.LockingStrengthNone, "testAccountId", "restrictedProxyId") + require.NoError(t, err) + + token := createSessionToken(t, proxy.SessionPrivateKey, "nonGroupUserId", "restricted-proxy.example.com") + + resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + Domain: "restricted-proxy.example.com", + SessionToken: token, + }) + + require.NoError(t, err) + assert.False(t, resp.Valid, "User not in group should be denied") + assert.Equal(t, "not_in_group", resp.DeniedReason) + assert.Equal(t, "nonGroupUserId", resp.UserId) +} + +func TestValidateSession_UserInDifferentAccount(t *testing.T) { + setup := setupValidateSessionTest(t) + defer setup.cleanup() + + proxy, err := setup.store.GetServiceByID(context.Background(), store.LockingStrengthNone, "testAccountId", "testProxyId") + require.NoError(t, err) + + token := createSessionToken(t, proxy.SessionPrivateKey, "otherAccountUserId", "test-proxy.example.com") + + resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + Domain: "test-proxy.example.com", + SessionToken: token, + }) + + require.NoError(t, err) + assert.False(t, resp.Valid, "User in different account should be denied") + assert.Equal(t, "account_mismatch", resp.DeniedReason) +} + +func TestValidateSession_UserNotFound(t *testing.T) { + setup := setupValidateSessionTest(t) + defer setup.cleanup() + + proxy, err := setup.store.GetServiceByID(context.Background(), store.LockingStrengthNone, "testAccountId", "testProxyId") + require.NoError(t, err) + + token := createSessionToken(t, proxy.SessionPrivateKey, "nonExistentUserId", "test-proxy.example.com") + + resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + Domain: "test-proxy.example.com", + SessionToken: token, + }) + + require.NoError(t, err) + assert.False(t, resp.Valid, "Non-existent user should be denied") + assert.Equal(t, "user_not_found", resp.DeniedReason) +} + +func TestValidateSession_ProxyNotFound(t *testing.T) { + setup := setupValidateSessionTest(t) + defer setup.cleanup() + + proxy, err := setup.store.GetServiceByID(context.Background(), store.LockingStrengthNone, "testAccountId", "testProxyId") + require.NoError(t, err) + + token := createSessionToken(t, proxy.SessionPrivateKey, "allowedUserId", "unknown-proxy.example.com") + + resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + Domain: "unknown-proxy.example.com", + SessionToken: token, + }) + + require.NoError(t, err) + assert.False(t, resp.Valid, "Unknown proxy should be denied") + assert.Equal(t, "service_not_found", resp.DeniedReason) +} + +func TestValidateSession_InvalidToken(t *testing.T) { + setup := setupValidateSessionTest(t) + defer setup.cleanup() + + resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + Domain: "test-proxy.example.com", + SessionToken: "invalid-token", + }) + + require.NoError(t, err) + assert.False(t, resp.Valid, "Invalid token should be denied") + assert.Equal(t, "invalid_token", resp.DeniedReason) +} + +func TestValidateSession_MissingDomain(t *testing.T) { + setup := setupValidateSessionTest(t) + defer setup.cleanup() + + resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + SessionToken: "some-token", + }) + + require.NoError(t, err) + assert.False(t, resp.Valid) + assert.Contains(t, resp.DeniedReason, "missing") +} + +func TestValidateSession_MissingToken(t *testing.T) { + setup := setupValidateSessionTest(t) + defer setup.cleanup() + + resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + Domain: "test-proxy.example.com", + }) + + require.NoError(t, err) + assert.False(t, resp.Valid) + assert.Contains(t, resp.DeniedReason, "missing") +} + +type testValidateSessionServiceManager struct { + store store.Store +} + +func (m *testValidateSessionServiceManager) GetAllServices(_ context.Context, _, _ string) ([]*service.Service, error) { + return nil, nil +} + +func (m *testValidateSessionServiceManager) GetService(_ context.Context, _, _, _ string) (*service.Service, error) { + return nil, nil +} + +func (m *testValidateSessionServiceManager) CreateService(_ context.Context, _, _ string, _ *service.Service) (*service.Service, error) { + return nil, nil +} + +func (m *testValidateSessionServiceManager) UpdateService(_ context.Context, _, _ string, _ *service.Service) (*service.Service, error) { + return nil, nil +} + +func (m *testValidateSessionServiceManager) DeleteService(_ context.Context, _, _, _ string) error { + return nil +} + +func (m *testValidateSessionServiceManager) DeleteAllServices(_ context.Context, _, _ string) error { + return nil +} + +func (m *testValidateSessionServiceManager) SetCertificateIssuedAt(_ context.Context, _, _ string) error { + return nil +} + +func (m *testValidateSessionServiceManager) SetStatus(_ context.Context, _, _ string, _ service.Status) error { + return nil +} + +func (m *testValidateSessionServiceManager) ReloadAllServicesForAccount(_ context.Context, _ string) error { + return nil +} + +func (m *testValidateSessionServiceManager) ReloadService(_ context.Context, _, _ string) error { + return nil +} + +func (m *testValidateSessionServiceManager) GetGlobalServices(ctx context.Context) ([]*service.Service, error) { + return m.store.GetServices(ctx, store.LockingStrengthNone) +} + +func (m *testValidateSessionServiceManager) GetServiceByID(ctx context.Context, accountID, proxyID string) (*service.Service, error) { + return m.store.GetServiceByID(ctx, store.LockingStrengthNone, accountID, proxyID) +} + +func (m *testValidateSessionServiceManager) GetAccountServices(ctx context.Context, accountID string) ([]*service.Service, error) { + return m.store.GetAccountServices(ctx, store.LockingStrengthNone, accountID) +} + +func (m *testValidateSessionServiceManager) GetServiceIDByTargetID(_ context.Context, _, _ string) (string, error) { + return "", nil +} + +func (m *testValidateSessionServiceManager) CreateServiceFromPeer(_ context.Context, _, _ string, _ *service.ExposeServiceRequest) (*service.ExposeServiceResponse, error) { + return nil, nil +} + +func (m *testValidateSessionServiceManager) RenewServiceFromPeer(_ context.Context, _, _, _ string) error { + return nil +} + +func (m *testValidateSessionServiceManager) StopServiceFromPeer(_ context.Context, _, _, _ string) error { + return nil +} + +func (m *testValidateSessionServiceManager) StartExposeReaper(_ context.Context) {} + +type testValidateSessionProxyManager struct{} + +func (m *testValidateSessionProxyManager) Connect(_ context.Context, _, _, _ string) error { + return nil +} + +func (m *testValidateSessionProxyManager) Disconnect(_ context.Context, _ string) error { + return nil +} + +func (m *testValidateSessionProxyManager) Heartbeat(_ context.Context, _ string) error { + return nil +} + +func (m *testValidateSessionProxyManager) GetActiveClusterAddresses(_ context.Context) ([]string, error) { + return nil, nil +} + +func (m *testValidateSessionProxyManager) CleanupStale(_ context.Context, _ time.Duration) error { + return nil +} + +type testValidateSessionUsersManager struct { + store store.Store +} + +func (m *testValidateSessionUsersManager) GetUser(ctx context.Context, userID string) (*types.User, error) { + return m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) +} diff --git a/management/server/account.go b/management/server/account.go index d453b87c3..75db36a5f 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -15,6 +15,7 @@ import ( "sync" "time" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" "github.com/netbirdio/netbird/management/server/job" "github.com/netbirdio/netbird/shared/auth" @@ -48,6 +49,7 @@ import ( "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/management/server/util" "github.com/netbirdio/netbird/route" + nbdomain "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/management/status" ) @@ -83,6 +85,7 @@ type DefaultAccountManager struct { proxyController port_forwarding.Controller settingsManager settings.Manager + serviceManager service.Manager // config contains the management server configuration config *nbconfig.Config @@ -112,6 +115,10 @@ type DefaultAccountManager struct { var _ account.Manager = (*DefaultAccountManager)(nil) +func (am *DefaultAccountManager) SetServiceManager(serviceManager service.Manager) { + am.serviceManager = serviceManager +} + func isUniqueConstraintError(err error) bool { switch { case strings.Contains(err.Error(), "(SQLSTATE 23505)"), @@ -231,7 +238,7 @@ func BuildManager( // enable single account mode only if configured by user and number of existing accounts is not grater than 1 am.singleAccountMode = singleAccountModeDomain != "" && accountsCounter <= 1 if am.singleAccountMode { - if !isDomainValid(singleAccountModeDomain) { + if !nbdomain.IsValidDomainNoWildcard(singleAccountModeDomain) { return nil, status.Errorf(status.InvalidArgument, "invalid domain \"%s\" provided for a single account mode. Please review your input for --single-account-mode-domain", singleAccountModeDomain) } am.singleAccountModeDomain = singleAccountModeDomain @@ -290,6 +297,7 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco var oldSettings *types.Settings var updateAccountPeers bool var groupChangesAffectPeers bool + var reloadReverseProxy bool err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { var groupsUpdated bool @@ -320,13 +328,15 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco if err = am.reallocateAccountPeerIPs(ctx, transaction, accountID, newSettings.NetworkRange); err != nil { return err } + reloadReverseProxy = true updateAccountPeers = true } if oldSettings.RoutingPeerDNSResolutionEnabled != newSettings.RoutingPeerDNSResolutionEnabled || oldSettings.LazyConnectionEnabled != newSettings.LazyConnectionEnabled || oldSettings.DNSDomain != newSettings.DNSDomain || - oldSettings.AutoUpdateVersion != newSettings.AutoUpdateVersion { + oldSettings.AutoUpdateVersion != newSettings.AutoUpdateVersion || + oldSettings.AutoUpdateAlways != newSettings.AutoUpdateAlways { updateAccountPeers = true } @@ -367,6 +377,8 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco am.handlePeerLoginExpirationSettings(ctx, oldSettings, newSettings, userID, accountID) am.handleGroupsPropagationSettings(ctx, oldSettings, newSettings, userID, accountID) am.handleAutoUpdateVersionSettings(ctx, oldSettings, newSettings, userID, accountID) + am.handleAutoUpdateAlwaysSettings(ctx, oldSettings, newSettings, userID, accountID) + am.handlePeerExposeSettings(ctx, oldSettings, newSettings, userID, accountID) if err = am.handleInactivityExpirationSettings(ctx, oldSettings, newSettings, userID, accountID); err != nil { return nil, err } @@ -384,6 +396,11 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco } am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountNetworkRangeUpdated, eventMeta) } + if reloadReverseProxy { + if err = am.serviceManager.ReloadAllServicesForAccount(ctx, accountID); err != nil { + log.WithContext(ctx).Warnf("failed to reload all services for account %s: %v", accountID, err) + } + } if updateAccountPeers || extraSettingsChanged || groupChangesAffectPeers { go am.UpdateAccountPeers(ctx, accountID) @@ -402,7 +419,7 @@ func (am *DefaultAccountManager) validateSettingsUpdate(ctx context.Context, tra return status.Errorf(status.InvalidArgument, "peer login expiration can't be smaller than one hour") } - if newSettings.DNSDomain != "" && !isDomainValid(newSettings.DNSDomain) { + if newSettings.DNSDomain != "" && !nbdomain.IsValidDomainNoWildcard(newSettings.DNSDomain) { return status.Errorf(status.InvalidArgument, "invalid domain \"%s\" provided for DNS domain", newSettings.DNSDomain) } @@ -478,6 +495,31 @@ func (am *DefaultAccountManager) handleAutoUpdateVersionSettings(ctx context.Con } } +func (am *DefaultAccountManager) handleAutoUpdateAlwaysSettings(ctx context.Context, oldSettings, newSettings *types.Settings, userID, accountID string) { + if oldSettings.AutoUpdateAlways != newSettings.AutoUpdateAlways { + if newSettings.AutoUpdateAlways { + am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountAutoUpdateAlwaysEnabled, nil) + } else { + am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountAutoUpdateAlwaysDisabled, nil) + } + } +} + +func (am *DefaultAccountManager) handlePeerExposeSettings(ctx context.Context, oldSettings, newSettings *types.Settings, userID, accountID string) { + oldEnabled := oldSettings.PeerExposeEnabled + newEnabled := newSettings.PeerExposeEnabled + + if oldEnabled == newEnabled { + return + } + + event := activity.AccountPeerExposeEnabled + if !newEnabled { + event = activity.AccountPeerExposeDisabled + } + am.StoreEvent(ctx, userID, accountID, accountID, event, nil) +} + func (am *DefaultAccountManager) handleInactivityExpirationSettings(ctx context.Context, oldSettings, newSettings *types.Settings, userID, accountID string) error { if newSettings.PeerInactivityExpirationEnabled { if oldSettings.PeerInactivityExpiration != newSettings.PeerInactivityExpiration { @@ -700,6 +742,11 @@ func (am *DefaultAccountManager) DeleteAccount(ctx context.Context, accountID, u return status.Errorf(status.Internal, "failed to build user infos for account %s: %v", accountID, err) } + err = am.serviceManager.DeleteAllServices(ctx, accountID, userID) + if err != nil { + return status.Errorf(status.Internal, "failed to delete service %s: %v", accountID, err) + } + for _, otherUser := range account.Users { if otherUser.Id == userID { continue @@ -794,6 +841,19 @@ func IsEmbeddedIdp(i idp.Manager) bool { return ok } +// IsLocalAuthDisabled checks if local (email/password) authentication is disabled. +// Returns true only when using embedded IDP with local auth disabled in config. +func IsLocalAuthDisabled(ctx context.Context, i idp.Manager) bool { + if isNil(i) { + return false + } + embeddedIdp, ok := i.(*idp.EmbeddedIdPManager) + if !ok { + return false + } + return embeddedIdp.IsLocalAuthDisabled() +} + // addAccountIDToIDPAppMeta update user's app metadata in idp manager func (am *DefaultAccountManager) addAccountIDToIDPAppMeta(ctx context.Context, userID string, accountID string) error { if !isNil(am.idpManager) && !IsEmbeddedIdp(am.idpManager) { @@ -1331,9 +1391,10 @@ func (am *DefaultAccountManager) GetAccountIDFromUserAuth(ctx context.Context, u if am.singleAccountMode && am.singleAccountModeDomain != "" { // This section is mostly related to self-hosted installations. // We override incoming domain claims to group users under a single account. - userAuth.Domain = am.singleAccountModeDomain - userAuth.DomainCategory = types.PrivateCategory - log.WithContext(ctx).Debugf("overriding JWT Domain and DomainCategory claims since single account mode is enabled") + err := am.updateUserAuthWithSingleMode(ctx, &userAuth) + if err != nil { + return "", "", err + } } accountID, err := am.getAccountIDWithAuthorizationClaims(ctx, userAuth) @@ -1366,6 +1427,35 @@ func (am *DefaultAccountManager) GetAccountIDFromUserAuth(ctx context.Context, u return accountID, user.Id, nil } +// updateUserAuthWithSingleMode modifies the userAuth with the single account domain, or if there is an existing account, with the domain of that account +func (am *DefaultAccountManager) updateUserAuthWithSingleMode(ctx context.Context, userAuth *auth.UserAuth) error { + userAuth.DomainCategory = types.PrivateCategory + userAuth.Domain = am.singleAccountModeDomain + + accountID, err := am.Store.GetAnyAccountID(ctx) + if err != nil { + if e, ok := status.FromError(err); !ok || e.Type() != status.NotFound { + return err + } + log.WithContext(ctx).Debugf("using singleAccountModeDomain to override JWT Domain and DomainCategory claims in single account mode") + return nil + } + + if accountID == "" { + log.WithContext(ctx).Debugf("using singleAccountModeDomain to override JWT Domain and DomainCategory claims in single account mode") + return nil + } + + domain, _, err := am.Store.GetAccountDomainAndCategory(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return err + } + userAuth.Domain = domain + + log.WithContext(ctx).Debugf("overriding JWT Domain and DomainCategory claims since single account mode is enabled") + return nil +} + // syncJWTGroups processes the JWT groups for a user, updates the account based on the groups, // and propagates changes to peers if group propagation is enabled. // requires userAuth to have been ValidateAndParseToken and EnsureUserAccessByJWTGroups by the AuthManager @@ -1656,13 +1746,13 @@ func domainIsUpToDate(domain string, domainCategory string, userAuth auth.UserAu return domainCategory == types.PrivateCategory || userAuth.DomainCategory != types.PrivateCategory || domain != userAuth.Domain } -func (am *DefaultAccountManager) SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) { +func (am *DefaultAccountManager) SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) { peer, netMap, postureChecks, dnsfwdPort, err := am.SyncPeer(ctx, types.PeerSync{WireGuardPubKey: peerPubKey, Meta: meta}, accountID) if err != nil { return nil, nil, nil, 0, fmt.Errorf("error syncing peer: %w", err) } - err = am.MarkPeerConnected(ctx, peerPubKey, true, realIP, accountID) + err = am.MarkPeerConnected(ctx, peerPubKey, true, realIP, accountID, syncTime) if err != nil { log.WithContext(ctx).Warnf("failed marking peer as connected %s %v", peerPubKey, err) } @@ -1670,8 +1760,20 @@ func (am *DefaultAccountManager) SyncAndMarkPeer(ctx context.Context, accountID return peer, netMap, postureChecks, dnsfwdPort, nil } -func (am *DefaultAccountManager) OnPeerDisconnected(ctx context.Context, accountID string, peerPubKey string) error { - err := am.MarkPeerConnected(ctx, peerPubKey, false, nil, accountID) +func (am *DefaultAccountManager) OnPeerDisconnected(ctx context.Context, accountID string, peerPubKey string, streamStartTime time.Time) error { + peer, err := am.Store.GetPeerByPeerPubKey(ctx, store.LockingStrengthNone, peerPubKey) + if err != nil { + log.WithContext(ctx).Warnf("failed to get peer %s for disconnect check: %v", peerPubKey, err) + return nil + } + + if peer.Status.LastSeen.After(streamStartTime) { + log.WithContext(ctx).Tracef("peer %s has newer activity (lastSeen=%s > streamStart=%s), skipping disconnect", + peerPubKey, peer.Status.LastSeen.Format(time.RFC3339), streamStartTime.Format(time.RFC3339)) + return nil + } + + err = am.MarkPeerConnected(ctx, peerPubKey, false, nil, accountID, time.Now().UTC()) if err != nil { log.WithContext(ctx).Warnf("failed marking peer as disconnected %s %v", peerPubKey, err) } @@ -1691,10 +1793,12 @@ func (am *DefaultAccountManager) SyncPeerMeta(ctx context.Context, peerPubKey st return nil } -var invalidDomainRegexp = regexp.MustCompile(`^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$`) +// isDomainValid validates public/IDP domains using stricter rules than internal DNS domains. +// Requires at least 2-char alphabetic TLD and no single-label domains. +var publicDomainRegexp = regexp.MustCompile(`^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$`) func isDomainValid(domain string) bool { - return invalidDomainRegexp.MatchString(domain) + return publicDomainRegexp.MatchString(domain) } func (am *DefaultAccountManager) onPeersInvalidated(ctx context.Context, accountID string, peerIDs []string) { diff --git a/management/server/account/manager.go b/management/server/account/manager.go index 5e9bb42a2..45af63ae8 100644 --- a/management/server/account/manager.go +++ b/management/server/account/manager.go @@ -1,11 +1,14 @@ package account +//go:generate go run github.com/golang/mock/mockgen -package account -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod + import ( "context" "net" "net/netip" "time" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" "github.com/netbirdio/netbird/shared/auth" nbdns "github.com/netbirdio/netbird/dns" @@ -58,13 +61,13 @@ type Manager interface { GetUserFromUserAuth(ctx context.Context, userAuth auth.UserAuth) (*types.User, error) ListUsers(ctx context.Context, accountID string) ([]*types.User, error) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) - MarkPeerConnected(ctx context.Context, peerKey string, connected bool, realIP net.IP, accountID string) error + MarkPeerConnected(ctx context.Context, peerKey string, connected bool, realIP net.IP, accountID string, syncTime time.Time) error DeletePeer(ctx context.Context, accountID, peerID, userID string) error - UpdatePeer(ctx context.Context, accountID, userID string, peer *nbpeer.Peer) (*nbpeer.Peer, error) + UpdatePeer(ctx context.Context, accountID, userID string, p *nbpeer.Peer) (*nbpeer.Peer, error) UpdatePeerIP(ctx context.Context, accountID, userID, peerID string, newIP netip.Addr) error GetNetworkMap(ctx context.Context, peerID string) (*types.NetworkMap, error) GetPeerNetwork(ctx context.Context, peerID string) (*types.Network, error) - AddPeer(ctx context.Context, accountID, setupKey, userID string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) + AddPeer(ctx context.Context, accountID, setupKey, userID string, p *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) CreatePAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenName string, expiresIn int) (*types.PersonalAccessTokenGenerated, error) DeletePAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenID string) error GetPAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenID string) (*types.PersonalAccessToken, error) @@ -114,8 +117,8 @@ type Manager interface { UpdateIntegratedValidator(ctx context.Context, accountID, userID, validator string, groups []string) error GroupValidation(ctx context.Context, accountId string, groups []string) (bool, error) GetValidatedPeers(ctx context.Context, accountID string) (map[string]struct{}, map[string]string, error) - SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) - OnPeerDisconnected(ctx context.Context, accountID string, peerPubKey string) error + SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) + OnPeerDisconnected(ctx context.Context, accountID string, peerPubKey string, streamStartTime time.Time) error SyncPeerMeta(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta) error FindExistingPostureCheck(accountID string, checks *posture.ChecksDefinition) (*posture.Checks, error) GetAccountIDForPeerKey(ctx context.Context, peerKey string) (string, error) @@ -139,4 +142,5 @@ type Manager interface { CreatePeerJob(ctx context.Context, accountID, peerID, userID string, job *types.Job) error GetAllPeerJobs(ctx context.Context, accountID, userID, peerID string) ([]*types.Job, error) GetPeerJobByID(ctx context.Context, accountID, userID, peerID, jobID string) (*types.Job, error) + SetServiceManager(serviceManager service.Manager) } diff --git a/management/server/account/manager_mock.go b/management/server/account/manager_mock.go new file mode 100644 index 000000000..90700c795 --- /dev/null +++ b/management/server/account/manager_mock.go @@ -0,0 +1,1738 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./manager.go + +// Package account is a generated GoMock package. +package account + +import ( + context "context" + net "net" + netip "net/netip" + reflect "reflect" + time "time" + + gomock "github.com/golang/mock/gomock" + dns "github.com/netbirdio/netbird/dns" + service "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + activity "github.com/netbirdio/netbird/management/server/activity" + idp "github.com/netbirdio/netbird/management/server/idp" + peer "github.com/netbirdio/netbird/management/server/peer" + posture "github.com/netbirdio/netbird/management/server/posture" + store "github.com/netbirdio/netbird/management/server/store" + types "github.com/netbirdio/netbird/management/server/types" + users "github.com/netbirdio/netbird/management/server/users" + route "github.com/netbirdio/netbird/route" + auth "github.com/netbirdio/netbird/shared/auth" + domain "github.com/netbirdio/netbird/shared/management/domain" +) + +// MockManager is a mock of Manager interface. +type MockManager struct { + ctrl *gomock.Controller + recorder *MockManagerMockRecorder +} + +// MockManagerMockRecorder is the mock recorder for MockManager. +type MockManagerMockRecorder struct { + mock *MockManager +} + +// NewMockManager creates a new mock instance. +func NewMockManager(ctrl *gomock.Controller) *MockManager { + mock := &MockManager{ctrl: ctrl} + mock.recorder = &MockManagerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockManager) EXPECT() *MockManagerMockRecorder { + return m.recorder +} + +// AcceptUserInvite mocks base method. +func (m *MockManager) AcceptUserInvite(ctx context.Context, token, password string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AcceptUserInvite", ctx, token, password) + ret0, _ := ret[0].(error) + return ret0 +} + +// AcceptUserInvite indicates an expected call of AcceptUserInvite. +func (mr *MockManagerMockRecorder) AcceptUserInvite(ctx, token, password interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AcceptUserInvite", reflect.TypeOf((*MockManager)(nil).AcceptUserInvite), ctx, token, password) +} + +// AccountExists mocks base method. +func (m *MockManager) AccountExists(ctx context.Context, accountID string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AccountExists", ctx, accountID) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AccountExists indicates an expected call of AccountExists. +func (mr *MockManagerMockRecorder) AccountExists(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AccountExists", reflect.TypeOf((*MockManager)(nil).AccountExists), ctx, accountID) +} + +// AddPeer mocks base method. +func (m *MockManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, p *peer.Peer, temporary bool) (*peer.Peer, *types.NetworkMap, []*posture.Checks, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddPeer", ctx, accountID, setupKey, userID, p, temporary) + ret0, _ := ret[0].(*peer.Peer) + ret1, _ := ret[1].(*types.NetworkMap) + ret2, _ := ret[2].([]*posture.Checks) + ret3, _ := ret[3].(error) + return ret0, ret1, ret2, ret3 +} + +// AddPeer indicates an expected call of AddPeer. +func (mr *MockManagerMockRecorder) AddPeer(ctx, accountID, setupKey, userID, p, temporary interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddPeer", reflect.TypeOf((*MockManager)(nil).AddPeer), ctx, accountID, setupKey, userID, p, temporary) +} + +// ApproveUser mocks base method. +func (m *MockManager) ApproveUser(ctx context.Context, accountID, initiatorUserID, targetUserID string) (*types.UserInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ApproveUser", ctx, accountID, initiatorUserID, targetUserID) + ret0, _ := ret[0].(*types.UserInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ApproveUser indicates an expected call of ApproveUser. +func (mr *MockManagerMockRecorder) ApproveUser(ctx, accountID, initiatorUserID, targetUserID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ApproveUser", reflect.TypeOf((*MockManager)(nil).ApproveUser), ctx, accountID, initiatorUserID, targetUserID) +} + +// BufferUpdateAccountPeers mocks base method. +func (m *MockManager) BufferUpdateAccountPeers(ctx context.Context, accountID string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "BufferUpdateAccountPeers", ctx, accountID) +} + +// BufferUpdateAccountPeers indicates an expected call of BufferUpdateAccountPeers. +func (mr *MockManagerMockRecorder) BufferUpdateAccountPeers(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BufferUpdateAccountPeers", reflect.TypeOf((*MockManager)(nil).BufferUpdateAccountPeers), ctx, accountID) +} + +// BuildUserInfosForAccount mocks base method. +func (m *MockManager) BuildUserInfosForAccount(ctx context.Context, accountID, initiatorUserID string, accountUsers []*types.User) (map[string]*types.UserInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BuildUserInfosForAccount", ctx, accountID, initiatorUserID, accountUsers) + ret0, _ := ret[0].(map[string]*types.UserInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// BuildUserInfosForAccount indicates an expected call of BuildUserInfosForAccount. +func (mr *MockManagerMockRecorder) BuildUserInfosForAccount(ctx, accountID, initiatorUserID, accountUsers interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BuildUserInfosForAccount", reflect.TypeOf((*MockManager)(nil).BuildUserInfosForAccount), ctx, accountID, initiatorUserID, accountUsers) +} + +// CreateGroup mocks base method. +func (m *MockManager) CreateGroup(ctx context.Context, accountID, userID string, group *types.Group) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateGroup", ctx, accountID, userID, group) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateGroup indicates an expected call of CreateGroup. +func (mr *MockManagerMockRecorder) CreateGroup(ctx, accountID, userID, group interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateGroup", reflect.TypeOf((*MockManager)(nil).CreateGroup), ctx, accountID, userID, group) +} + +// CreateGroups mocks base method. +func (m *MockManager) CreateGroups(ctx context.Context, accountID, userID string, newGroups []*types.Group) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateGroups", ctx, accountID, userID, newGroups) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateGroups indicates an expected call of CreateGroups. +func (mr *MockManagerMockRecorder) CreateGroups(ctx, accountID, userID, newGroups interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateGroups", reflect.TypeOf((*MockManager)(nil).CreateGroups), ctx, accountID, userID, newGroups) +} + +// CreateIdentityProvider mocks base method. +func (m *MockManager) CreateIdentityProvider(ctx context.Context, accountID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateIdentityProvider", ctx, accountID, userID, idp) + ret0, _ := ret[0].(*types.IdentityProvider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateIdentityProvider indicates an expected call of CreateIdentityProvider. +func (mr *MockManagerMockRecorder) CreateIdentityProvider(ctx, accountID, userID, idp interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateIdentityProvider", reflect.TypeOf((*MockManager)(nil).CreateIdentityProvider), ctx, accountID, userID, idp) +} + +// CreateNameServerGroup mocks base method. +func (m *MockManager) CreateNameServerGroup(ctx context.Context, accountID, name, description string, nameServerList []dns.NameServer, groups []string, primary bool, domains []string, enabled bool, userID string, searchDomainsEnabled bool) (*dns.NameServerGroup, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateNameServerGroup", ctx, accountID, name, description, nameServerList, groups, primary, domains, enabled, userID, searchDomainsEnabled) + ret0, _ := ret[0].(*dns.NameServerGroup) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateNameServerGroup indicates an expected call of CreateNameServerGroup. +func (mr *MockManagerMockRecorder) CreateNameServerGroup(ctx, accountID, name, description, nameServerList, groups, primary, domains, enabled, userID, searchDomainsEnabled interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateNameServerGroup", reflect.TypeOf((*MockManager)(nil).CreateNameServerGroup), ctx, accountID, name, description, nameServerList, groups, primary, domains, enabled, userID, searchDomainsEnabled) +} + +// CreatePAT mocks base method. +func (m *MockManager) CreatePAT(ctx context.Context, accountID, initiatorUserID, targetUserID, tokenName string, expiresIn int) (*types.PersonalAccessTokenGenerated, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreatePAT", ctx, accountID, initiatorUserID, targetUserID, tokenName, expiresIn) + ret0, _ := ret[0].(*types.PersonalAccessTokenGenerated) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreatePAT indicates an expected call of CreatePAT. +func (mr *MockManagerMockRecorder) CreatePAT(ctx, accountID, initiatorUserID, targetUserID, tokenName, expiresIn interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePAT", reflect.TypeOf((*MockManager)(nil).CreatePAT), ctx, accountID, initiatorUserID, targetUserID, tokenName, expiresIn) +} + +// CreatePeerJob mocks base method. +func (m *MockManager) CreatePeerJob(ctx context.Context, accountID, peerID, userID string, job *types.Job) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreatePeerJob", ctx, accountID, peerID, userID, job) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreatePeerJob indicates an expected call of CreatePeerJob. +func (mr *MockManagerMockRecorder) CreatePeerJob(ctx, accountID, peerID, userID, job interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePeerJob", reflect.TypeOf((*MockManager)(nil).CreatePeerJob), ctx, accountID, peerID, userID, job) +} + +// CreateRoute mocks base method. +func (m *MockManager) CreateRoute(ctx context.Context, accountID string, prefix netip.Prefix, networkType route.NetworkType, domains domain.List, peerID string, peerGroupIDs []string, description string, netID route.NetID, masquerade bool, metric int, groups, accessControlGroupIDs []string, enabled bool, userID string, keepRoute, skipAutoApply bool) (*route.Route, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateRoute", ctx, accountID, prefix, networkType, domains, peerID, peerGroupIDs, description, netID, masquerade, metric, groups, accessControlGroupIDs, enabled, userID, keepRoute, skipAutoApply) + ret0, _ := ret[0].(*route.Route) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateRoute indicates an expected call of CreateRoute. +func (mr *MockManagerMockRecorder) CreateRoute(ctx, accountID, prefix, networkType, domains, peerID, peerGroupIDs, description, netID, masquerade, metric, groups, accessControlGroupIDs, enabled, userID, keepRoute, skipAutoApply interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateRoute", reflect.TypeOf((*MockManager)(nil).CreateRoute), ctx, accountID, prefix, networkType, domains, peerID, peerGroupIDs, description, netID, masquerade, metric, groups, accessControlGroupIDs, enabled, userID, keepRoute, skipAutoApply) +} + +// CreateSetupKey mocks base method. +func (m *MockManager) CreateSetupKey(ctx context.Context, accountID, keyName string, keyType types.SetupKeyType, expiresIn time.Duration, autoGroups []string, usageLimit int, userID string, ephemeral, allowExtraDNSLabels bool) (*types.SetupKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateSetupKey", ctx, accountID, keyName, keyType, expiresIn, autoGroups, usageLimit, userID, ephemeral, allowExtraDNSLabels) + ret0, _ := ret[0].(*types.SetupKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateSetupKey indicates an expected call of CreateSetupKey. +func (mr *MockManagerMockRecorder) CreateSetupKey(ctx, accountID, keyName, keyType, expiresIn, autoGroups, usageLimit, userID, ephemeral, allowExtraDNSLabels interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateSetupKey", reflect.TypeOf((*MockManager)(nil).CreateSetupKey), ctx, accountID, keyName, keyType, expiresIn, autoGroups, usageLimit, userID, ephemeral, allowExtraDNSLabels) +} + +// CreateUser mocks base method. +func (m *MockManager) CreateUser(ctx context.Context, accountID, initiatorUserID string, key *types.UserInfo) (*types.UserInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateUser", ctx, accountID, initiatorUserID, key) + ret0, _ := ret[0].(*types.UserInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateUser indicates an expected call of CreateUser. +func (mr *MockManagerMockRecorder) CreateUser(ctx, accountID, initiatorUserID, key interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUser", reflect.TypeOf((*MockManager)(nil).CreateUser), ctx, accountID, initiatorUserID, key) +} + +// CreateUserInvite mocks base method. +func (m *MockManager) CreateUserInvite(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateUserInvite", ctx, accountID, initiatorUserID, invite, expiresIn) + ret0, _ := ret[0].(*types.UserInvite) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateUserInvite indicates an expected call of CreateUserInvite. +func (mr *MockManagerMockRecorder) CreateUserInvite(ctx, accountID, initiatorUserID, invite, expiresIn interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUserInvite", reflect.TypeOf((*MockManager)(nil).CreateUserInvite), ctx, accountID, initiatorUserID, invite, expiresIn) +} + +// DeleteAccount mocks base method. +func (m *MockManager) DeleteAccount(ctx context.Context, accountID, userID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAccount", ctx, accountID, userID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAccount indicates an expected call of DeleteAccount. +func (mr *MockManagerMockRecorder) DeleteAccount(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAccount", reflect.TypeOf((*MockManager)(nil).DeleteAccount), ctx, accountID, userID) +} + +// DeleteGroup mocks base method. +func (m *MockManager) DeleteGroup(ctx context.Context, accountId, userId, groupID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteGroup", ctx, accountId, userId, groupID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteGroup indicates an expected call of DeleteGroup. +func (mr *MockManagerMockRecorder) DeleteGroup(ctx, accountId, userId, groupID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteGroup", reflect.TypeOf((*MockManager)(nil).DeleteGroup), ctx, accountId, userId, groupID) +} + +// DeleteGroups mocks base method. +func (m *MockManager) DeleteGroups(ctx context.Context, accountId, userId string, groupIDs []string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteGroups", ctx, accountId, userId, groupIDs) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteGroups indicates an expected call of DeleteGroups. +func (mr *MockManagerMockRecorder) DeleteGroups(ctx, accountId, userId, groupIDs interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteGroups", reflect.TypeOf((*MockManager)(nil).DeleteGroups), ctx, accountId, userId, groupIDs) +} + +// DeleteIdentityProvider mocks base method. +func (m *MockManager) DeleteIdentityProvider(ctx context.Context, accountID, idpID, userID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteIdentityProvider", ctx, accountID, idpID, userID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteIdentityProvider indicates an expected call of DeleteIdentityProvider. +func (mr *MockManagerMockRecorder) DeleteIdentityProvider(ctx, accountID, idpID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteIdentityProvider", reflect.TypeOf((*MockManager)(nil).DeleteIdentityProvider), ctx, accountID, idpID, userID) +} + +// DeleteNameServerGroup mocks base method. +func (m *MockManager) DeleteNameServerGroup(ctx context.Context, accountID, nsGroupID, userID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteNameServerGroup", ctx, accountID, nsGroupID, userID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteNameServerGroup indicates an expected call of DeleteNameServerGroup. +func (mr *MockManagerMockRecorder) DeleteNameServerGroup(ctx, accountID, nsGroupID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteNameServerGroup", reflect.TypeOf((*MockManager)(nil).DeleteNameServerGroup), ctx, accountID, nsGroupID, userID) +} + +// DeletePAT mocks base method. +func (m *MockManager) DeletePAT(ctx context.Context, accountID, initiatorUserID, targetUserID, tokenID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeletePAT", ctx, accountID, initiatorUserID, targetUserID, tokenID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeletePAT indicates an expected call of DeletePAT. +func (mr *MockManagerMockRecorder) DeletePAT(ctx, accountID, initiatorUserID, targetUserID, tokenID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePAT", reflect.TypeOf((*MockManager)(nil).DeletePAT), ctx, accountID, initiatorUserID, targetUserID, tokenID) +} + +// DeletePeer mocks base method. +func (m *MockManager) DeletePeer(ctx context.Context, accountID, peerID, userID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeletePeer", ctx, accountID, peerID, userID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeletePeer indicates an expected call of DeletePeer. +func (mr *MockManagerMockRecorder) DeletePeer(ctx, accountID, peerID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePeer", reflect.TypeOf((*MockManager)(nil).DeletePeer), ctx, accountID, peerID, userID) +} + +// DeletePolicy mocks base method. +func (m *MockManager) DeletePolicy(ctx context.Context, accountID, policyID, userID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeletePolicy", ctx, accountID, policyID, userID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeletePolicy indicates an expected call of DeletePolicy. +func (mr *MockManagerMockRecorder) DeletePolicy(ctx, accountID, policyID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePolicy", reflect.TypeOf((*MockManager)(nil).DeletePolicy), ctx, accountID, policyID, userID) +} + +// DeletePostureChecks mocks base method. +func (m *MockManager) DeletePostureChecks(ctx context.Context, accountID, postureChecksID, userID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeletePostureChecks", ctx, accountID, postureChecksID, userID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeletePostureChecks indicates an expected call of DeletePostureChecks. +func (mr *MockManagerMockRecorder) DeletePostureChecks(ctx, accountID, postureChecksID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePostureChecks", reflect.TypeOf((*MockManager)(nil).DeletePostureChecks), ctx, accountID, postureChecksID, userID) +} + +// DeleteRegularUsers mocks base method. +func (m *MockManager) DeleteRegularUsers(ctx context.Context, accountID, initiatorUserID string, targetUserIDs []string, userInfos map[string]*types.UserInfo) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteRegularUsers", ctx, accountID, initiatorUserID, targetUserIDs, userInfos) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteRegularUsers indicates an expected call of DeleteRegularUsers. +func (mr *MockManagerMockRecorder) DeleteRegularUsers(ctx, accountID, initiatorUserID, targetUserIDs, userInfos interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteRegularUsers", reflect.TypeOf((*MockManager)(nil).DeleteRegularUsers), ctx, accountID, initiatorUserID, targetUserIDs, userInfos) +} + +// DeleteRoute mocks base method. +func (m *MockManager) DeleteRoute(ctx context.Context, accountID string, routeID route.ID, userID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteRoute", ctx, accountID, routeID, userID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteRoute indicates an expected call of DeleteRoute. +func (mr *MockManagerMockRecorder) DeleteRoute(ctx, accountID, routeID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteRoute", reflect.TypeOf((*MockManager)(nil).DeleteRoute), ctx, accountID, routeID, userID) +} + +// DeleteSetupKey mocks base method. +func (m *MockManager) DeleteSetupKey(ctx context.Context, accountID, userID, keyID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteSetupKey", ctx, accountID, userID, keyID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteSetupKey indicates an expected call of DeleteSetupKey. +func (mr *MockManagerMockRecorder) DeleteSetupKey(ctx, accountID, userID, keyID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteSetupKey", reflect.TypeOf((*MockManager)(nil).DeleteSetupKey), ctx, accountID, userID, keyID) +} + +// DeleteUser mocks base method. +func (m *MockManager) DeleteUser(ctx context.Context, accountID, initiatorUserID, targetUserID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteUser", ctx, accountID, initiatorUserID, targetUserID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteUser indicates an expected call of DeleteUser. +func (mr *MockManagerMockRecorder) DeleteUser(ctx, accountID, initiatorUserID, targetUserID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUser", reflect.TypeOf((*MockManager)(nil).DeleteUser), ctx, accountID, initiatorUserID, targetUserID) +} + +// DeleteUserInvite mocks base method. +func (m *MockManager) DeleteUserInvite(ctx context.Context, accountID, initiatorUserID, inviteID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteUserInvite", ctx, accountID, initiatorUserID, inviteID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteUserInvite indicates an expected call of DeleteUserInvite. +func (mr *MockManagerMockRecorder) DeleteUserInvite(ctx, accountID, initiatorUserID, inviteID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUserInvite", reflect.TypeOf((*MockManager)(nil).DeleteUserInvite), ctx, accountID, initiatorUserID, inviteID) +} + +// FindExistingPostureCheck mocks base method. +func (m *MockManager) FindExistingPostureCheck(accountID string, checks *posture.ChecksDefinition) (*posture.Checks, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindExistingPostureCheck", accountID, checks) + ret0, _ := ret[0].(*posture.Checks) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindExistingPostureCheck indicates an expected call of FindExistingPostureCheck. +func (mr *MockManagerMockRecorder) FindExistingPostureCheck(accountID, checks interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindExistingPostureCheck", reflect.TypeOf((*MockManager)(nil).FindExistingPostureCheck), accountID, checks) +} + +// GetAccount mocks base method. +func (m *MockManager) GetAccount(ctx context.Context, accountID string) (*types.Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccount", ctx, accountID) + ret0, _ := ret[0].(*types.Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccount indicates an expected call of GetAccount. +func (mr *MockManagerMockRecorder) GetAccount(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccount", reflect.TypeOf((*MockManager)(nil).GetAccount), ctx, accountID) +} + +// GetAccountByID mocks base method. +func (m *MockManager) GetAccountByID(ctx context.Context, accountID, userID string) (*types.Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountByID", ctx, accountID, userID) + ret0, _ := ret[0].(*types.Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountByID indicates an expected call of GetAccountByID. +func (mr *MockManagerMockRecorder) GetAccountByID(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountByID", reflect.TypeOf((*MockManager)(nil).GetAccountByID), ctx, accountID, userID) +} + +// GetAccountIDByUserID mocks base method. +func (m *MockManager) GetAccountIDByUserID(ctx context.Context, userAuth auth.UserAuth) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountIDByUserID", ctx, userAuth) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountIDByUserID indicates an expected call of GetAccountIDByUserID. +func (mr *MockManagerMockRecorder) GetAccountIDByUserID(ctx, userAuth interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountIDByUserID", reflect.TypeOf((*MockManager)(nil).GetAccountIDByUserID), ctx, userAuth) +} + +// GetAccountIDForPeerKey mocks base method. +func (m *MockManager) GetAccountIDForPeerKey(ctx context.Context, peerKey string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountIDForPeerKey", ctx, peerKey) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountIDForPeerKey indicates an expected call of GetAccountIDForPeerKey. +func (mr *MockManagerMockRecorder) GetAccountIDForPeerKey(ctx, peerKey interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountIDForPeerKey", reflect.TypeOf((*MockManager)(nil).GetAccountIDForPeerKey), ctx, peerKey) +} + +// GetAccountIDFromUserAuth mocks base method. +func (m *MockManager) GetAccountIDFromUserAuth(ctx context.Context, userAuth auth.UserAuth) (string, string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountIDFromUserAuth", ctx, userAuth) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(string) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetAccountIDFromUserAuth indicates an expected call of GetAccountIDFromUserAuth. +func (mr *MockManagerMockRecorder) GetAccountIDFromUserAuth(ctx, userAuth interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountIDFromUserAuth", reflect.TypeOf((*MockManager)(nil).GetAccountIDFromUserAuth), ctx, userAuth) +} + +// GetAccountMeta mocks base method. +func (m *MockManager) GetAccountMeta(ctx context.Context, accountID, userID string) (*types.AccountMeta, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountMeta", ctx, accountID, userID) + ret0, _ := ret[0].(*types.AccountMeta) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountMeta indicates an expected call of GetAccountMeta. +func (mr *MockManagerMockRecorder) GetAccountMeta(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountMeta", reflect.TypeOf((*MockManager)(nil).GetAccountMeta), ctx, accountID, userID) +} + +// GetAccountOnboarding mocks base method. +func (m *MockManager) GetAccountOnboarding(ctx context.Context, accountID, userID string) (*types.AccountOnboarding, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountOnboarding", ctx, accountID, userID) + ret0, _ := ret[0].(*types.AccountOnboarding) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountOnboarding indicates an expected call of GetAccountOnboarding. +func (mr *MockManagerMockRecorder) GetAccountOnboarding(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountOnboarding", reflect.TypeOf((*MockManager)(nil).GetAccountOnboarding), ctx, accountID, userID) +} + +// GetAccountSettings mocks base method. +func (m *MockManager) GetAccountSettings(ctx context.Context, accountID, userID string) (*types.Settings, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountSettings", ctx, accountID, userID) + ret0, _ := ret[0].(*types.Settings) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountSettings indicates an expected call of GetAccountSettings. +func (mr *MockManagerMockRecorder) GetAccountSettings(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountSettings", reflect.TypeOf((*MockManager)(nil).GetAccountSettings), ctx, accountID, userID) +} + +// GetAllGroups mocks base method. +func (m *MockManager) GetAllGroups(ctx context.Context, accountID, userID string) ([]*types.Group, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllGroups", ctx, accountID, userID) + ret0, _ := ret[0].([]*types.Group) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAllGroups indicates an expected call of GetAllGroups. +func (mr *MockManagerMockRecorder) GetAllGroups(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllGroups", reflect.TypeOf((*MockManager)(nil).GetAllGroups), ctx, accountID, userID) +} + +// GetAllPATs mocks base method. +func (m *MockManager) GetAllPATs(ctx context.Context, accountID, initiatorUserID, targetUserID string) ([]*types.PersonalAccessToken, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllPATs", ctx, accountID, initiatorUserID, targetUserID) + ret0, _ := ret[0].([]*types.PersonalAccessToken) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAllPATs indicates an expected call of GetAllPATs. +func (mr *MockManagerMockRecorder) GetAllPATs(ctx, accountID, initiatorUserID, targetUserID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllPATs", reflect.TypeOf((*MockManager)(nil).GetAllPATs), ctx, accountID, initiatorUserID, targetUserID) +} + +// GetAllPeerJobs mocks base method. +func (m *MockManager) GetAllPeerJobs(ctx context.Context, accountID, userID, peerID string) ([]*types.Job, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllPeerJobs", ctx, accountID, userID, peerID) + ret0, _ := ret[0].([]*types.Job) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAllPeerJobs indicates an expected call of GetAllPeerJobs. +func (mr *MockManagerMockRecorder) GetAllPeerJobs(ctx, accountID, userID, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllPeerJobs", reflect.TypeOf((*MockManager)(nil).GetAllPeerJobs), ctx, accountID, userID, peerID) +} + +// GetCurrentUserInfo mocks base method. +func (m *MockManager) GetCurrentUserInfo(ctx context.Context, userAuth auth.UserAuth) (*users.UserInfoWithPermissions, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetCurrentUserInfo", ctx, userAuth) + ret0, _ := ret[0].(*users.UserInfoWithPermissions) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetCurrentUserInfo indicates an expected call of GetCurrentUserInfo. +func (mr *MockManagerMockRecorder) GetCurrentUserInfo(ctx, userAuth interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCurrentUserInfo", reflect.TypeOf((*MockManager)(nil).GetCurrentUserInfo), ctx, userAuth) +} + +// GetDNSSettings mocks base method. +func (m *MockManager) GetDNSSettings(ctx context.Context, accountID, userID string) (*types.DNSSettings, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetDNSSettings", ctx, accountID, userID) + ret0, _ := ret[0].(*types.DNSSettings) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetDNSSettings indicates an expected call of GetDNSSettings. +func (mr *MockManagerMockRecorder) GetDNSSettings(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDNSSettings", reflect.TypeOf((*MockManager)(nil).GetDNSSettings), ctx, accountID, userID) +} + +// GetEvents mocks base method. +func (m *MockManager) GetEvents(ctx context.Context, accountID, userID string) ([]*activity.Event, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetEvents", ctx, accountID, userID) + ret0, _ := ret[0].([]*activity.Event) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetEvents indicates an expected call of GetEvents. +func (mr *MockManagerMockRecorder) GetEvents(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEvents", reflect.TypeOf((*MockManager)(nil).GetEvents), ctx, accountID, userID) +} + +// GetExternalCacheManager mocks base method. +func (m *MockManager) GetExternalCacheManager() ExternalCacheManager { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetExternalCacheManager") + ret0, _ := ret[0].(ExternalCacheManager) + return ret0 +} + +// GetExternalCacheManager indicates an expected call of GetExternalCacheManager. +func (mr *MockManagerMockRecorder) GetExternalCacheManager() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExternalCacheManager", reflect.TypeOf((*MockManager)(nil).GetExternalCacheManager)) +} + +// GetGroup mocks base method. +func (m *MockManager) GetGroup(ctx context.Context, accountId, groupID, userID string) (*types.Group, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGroup", ctx, accountId, groupID, userID) + ret0, _ := ret[0].(*types.Group) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetGroup indicates an expected call of GetGroup. +func (mr *MockManagerMockRecorder) GetGroup(ctx, accountId, groupID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroup", reflect.TypeOf((*MockManager)(nil).GetGroup), ctx, accountId, groupID, userID) +} + +// GetGroupByName mocks base method. +func (m *MockManager) GetGroupByName(ctx context.Context, groupName, accountID string) (*types.Group, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGroupByName", ctx, groupName, accountID) + ret0, _ := ret[0].(*types.Group) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetGroupByName indicates an expected call of GetGroupByName. +func (mr *MockManagerMockRecorder) GetGroupByName(ctx, groupName, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupByName", reflect.TypeOf((*MockManager)(nil).GetGroupByName), ctx, groupName, accountID) +} + +// GetIdentityProvider mocks base method. +func (m *MockManager) GetIdentityProvider(ctx context.Context, accountID, idpID, userID string) (*types.IdentityProvider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetIdentityProvider", ctx, accountID, idpID, userID) + ret0, _ := ret[0].(*types.IdentityProvider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetIdentityProvider indicates an expected call of GetIdentityProvider. +func (mr *MockManagerMockRecorder) GetIdentityProvider(ctx, accountID, idpID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetIdentityProvider", reflect.TypeOf((*MockManager)(nil).GetIdentityProvider), ctx, accountID, idpID, userID) +} + +// GetIdentityProviders mocks base method. +func (m *MockManager) GetIdentityProviders(ctx context.Context, accountID, userID string) ([]*types.IdentityProvider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetIdentityProviders", ctx, accountID, userID) + ret0, _ := ret[0].([]*types.IdentityProvider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetIdentityProviders indicates an expected call of GetIdentityProviders. +func (mr *MockManagerMockRecorder) GetIdentityProviders(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetIdentityProviders", reflect.TypeOf((*MockManager)(nil).GetIdentityProviders), ctx, accountID, userID) +} + +// GetIdpManager mocks base method. +func (m *MockManager) GetIdpManager() idp.Manager { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetIdpManager") + ret0, _ := ret[0].(idp.Manager) + return ret0 +} + +// GetIdpManager indicates an expected call of GetIdpManager. +func (mr *MockManagerMockRecorder) GetIdpManager() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetIdpManager", reflect.TypeOf((*MockManager)(nil).GetIdpManager)) +} + +// GetNameServerGroup mocks base method. +func (m *MockManager) GetNameServerGroup(ctx context.Context, accountID, userID, nsGroupID string) (*dns.NameServerGroup, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNameServerGroup", ctx, accountID, userID, nsGroupID) + ret0, _ := ret[0].(*dns.NameServerGroup) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNameServerGroup indicates an expected call of GetNameServerGroup. +func (mr *MockManagerMockRecorder) GetNameServerGroup(ctx, accountID, userID, nsGroupID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNameServerGroup", reflect.TypeOf((*MockManager)(nil).GetNameServerGroup), ctx, accountID, userID, nsGroupID) +} + +// GetNetworkMap mocks base method. +func (m *MockManager) GetNetworkMap(ctx context.Context, peerID string) (*types.NetworkMap, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNetworkMap", ctx, peerID) + ret0, _ := ret[0].(*types.NetworkMap) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNetworkMap indicates an expected call of GetNetworkMap. +func (mr *MockManagerMockRecorder) GetNetworkMap(ctx, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkMap", reflect.TypeOf((*MockManager)(nil).GetNetworkMap), ctx, peerID) +} + +// GetOrCreateAccountByPrivateDomain mocks base method. +func (m *MockManager) GetOrCreateAccountByPrivateDomain(ctx context.Context, initiatorId, domain string) (*types.Account, bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOrCreateAccountByPrivateDomain", ctx, initiatorId, domain) + ret0, _ := ret[0].(*types.Account) + ret1, _ := ret[1].(bool) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetOrCreateAccountByPrivateDomain indicates an expected call of GetOrCreateAccountByPrivateDomain. +func (mr *MockManagerMockRecorder) GetOrCreateAccountByPrivateDomain(ctx, initiatorId, domain interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrCreateAccountByPrivateDomain", reflect.TypeOf((*MockManager)(nil).GetOrCreateAccountByPrivateDomain), ctx, initiatorId, domain) +} + +// GetOrCreateAccountByUser mocks base method. +func (m *MockManager) GetOrCreateAccountByUser(ctx context.Context, userAuth auth.UserAuth) (*types.Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOrCreateAccountByUser", ctx, userAuth) + ret0, _ := ret[0].(*types.Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetOrCreateAccountByUser indicates an expected call of GetOrCreateAccountByUser. +func (mr *MockManagerMockRecorder) GetOrCreateAccountByUser(ctx, userAuth interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrCreateAccountByUser", reflect.TypeOf((*MockManager)(nil).GetOrCreateAccountByUser), ctx, userAuth) +} + +// GetOwnerInfo mocks base method. +func (m *MockManager) GetOwnerInfo(ctx context.Context, accountId string) (*types.UserInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOwnerInfo", ctx, accountId) + ret0, _ := ret[0].(*types.UserInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetOwnerInfo indicates an expected call of GetOwnerInfo. +func (mr *MockManagerMockRecorder) GetOwnerInfo(ctx, accountId interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOwnerInfo", reflect.TypeOf((*MockManager)(nil).GetOwnerInfo), ctx, accountId) +} + +// GetPAT mocks base method. +func (m *MockManager) GetPAT(ctx context.Context, accountID, initiatorUserID, targetUserID, tokenID string) (*types.PersonalAccessToken, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPAT", ctx, accountID, initiatorUserID, targetUserID, tokenID) + ret0, _ := ret[0].(*types.PersonalAccessToken) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPAT indicates an expected call of GetPAT. +func (mr *MockManagerMockRecorder) GetPAT(ctx, accountID, initiatorUserID, targetUserID, tokenID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPAT", reflect.TypeOf((*MockManager)(nil).GetPAT), ctx, accountID, initiatorUserID, targetUserID, tokenID) +} + +// GetPeer mocks base method. +func (m *MockManager) GetPeer(ctx context.Context, accountID, peerID, userID string) (*peer.Peer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeer", ctx, accountID, peerID, userID) + ret0, _ := ret[0].(*peer.Peer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeer indicates an expected call of GetPeer. +func (mr *MockManagerMockRecorder) GetPeer(ctx, accountID, peerID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeer", reflect.TypeOf((*MockManager)(nil).GetPeer), ctx, accountID, peerID, userID) +} + +// GetPeerGroups mocks base method. +func (m *MockManager) GetPeerGroups(ctx context.Context, accountID, peerID string) ([]*types.Group, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerGroups", ctx, accountID, peerID) + ret0, _ := ret[0].([]*types.Group) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeerGroups indicates an expected call of GetPeerGroups. +func (mr *MockManagerMockRecorder) GetPeerGroups(ctx, accountID, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerGroups", reflect.TypeOf((*MockManager)(nil).GetPeerGroups), ctx, accountID, peerID) +} + +// GetPeerJobByID mocks base method. +func (m *MockManager) GetPeerJobByID(ctx context.Context, accountID, userID, peerID, jobID string) (*types.Job, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerJobByID", ctx, accountID, userID, peerID, jobID) + ret0, _ := ret[0].(*types.Job) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeerJobByID indicates an expected call of GetPeerJobByID. +func (mr *MockManagerMockRecorder) GetPeerJobByID(ctx, accountID, userID, peerID, jobID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerJobByID", reflect.TypeOf((*MockManager)(nil).GetPeerJobByID), ctx, accountID, userID, peerID, jobID) +} + +// GetPeerNetwork mocks base method. +func (m *MockManager) GetPeerNetwork(ctx context.Context, peerID string) (*types.Network, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerNetwork", ctx, peerID) + ret0, _ := ret[0].(*types.Network) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeerNetwork indicates an expected call of GetPeerNetwork. +func (mr *MockManagerMockRecorder) GetPeerNetwork(ctx, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerNetwork", reflect.TypeOf((*MockManager)(nil).GetPeerNetwork), ctx, peerID) +} + +// GetPeers mocks base method. +func (m *MockManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*peer.Peer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeers", ctx, accountID, userID, nameFilter, ipFilter) + ret0, _ := ret[0].([]*peer.Peer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeers indicates an expected call of GetPeers. +func (mr *MockManagerMockRecorder) GetPeers(ctx, accountID, userID, nameFilter, ipFilter interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeers", reflect.TypeOf((*MockManager)(nil).GetPeers), ctx, accountID, userID, nameFilter, ipFilter) +} + +// GetPolicy mocks base method. +func (m *MockManager) GetPolicy(ctx context.Context, accountID, policyID, userID string) (*types.Policy, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPolicy", ctx, accountID, policyID, userID) + ret0, _ := ret[0].(*types.Policy) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPolicy indicates an expected call of GetPolicy. +func (mr *MockManagerMockRecorder) GetPolicy(ctx, accountID, policyID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPolicy", reflect.TypeOf((*MockManager)(nil).GetPolicy), ctx, accountID, policyID, userID) +} + +// GetPostureChecks mocks base method. +func (m *MockManager) GetPostureChecks(ctx context.Context, accountID, postureChecksID, userID string) (*posture.Checks, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPostureChecks", ctx, accountID, postureChecksID, userID) + ret0, _ := ret[0].(*posture.Checks) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPostureChecks indicates an expected call of GetPostureChecks. +func (mr *MockManagerMockRecorder) GetPostureChecks(ctx, accountID, postureChecksID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPostureChecks", reflect.TypeOf((*MockManager)(nil).GetPostureChecks), ctx, accountID, postureChecksID, userID) +} + +// GetRoute mocks base method. +func (m *MockManager) GetRoute(ctx context.Context, accountID string, routeID route.ID, userID string) (*route.Route, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetRoute", ctx, accountID, routeID, userID) + ret0, _ := ret[0].(*route.Route) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetRoute indicates an expected call of GetRoute. +func (mr *MockManagerMockRecorder) GetRoute(ctx, accountID, routeID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRoute", reflect.TypeOf((*MockManager)(nil).GetRoute), ctx, accountID, routeID, userID) +} + +// GetSetupKey mocks base method. +func (m *MockManager) GetSetupKey(ctx context.Context, accountID, userID, keyID string) (*types.SetupKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSetupKey", ctx, accountID, userID, keyID) + ret0, _ := ret[0].(*types.SetupKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetSetupKey indicates an expected call of GetSetupKey. +func (mr *MockManagerMockRecorder) GetSetupKey(ctx, accountID, userID, keyID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSetupKey", reflect.TypeOf((*MockManager)(nil).GetSetupKey), ctx, accountID, userID, keyID) +} + +// GetStore mocks base method. +func (m *MockManager) GetStore() store.Store { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetStore") + ret0, _ := ret[0].(store.Store) + return ret0 +} + +// GetStore indicates an expected call of GetStore. +func (mr *MockManagerMockRecorder) GetStore() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetStore", reflect.TypeOf((*MockManager)(nil).GetStore)) +} + +// GetUserByID mocks base method. +func (m *MockManager) GetUserByID(ctx context.Context, id string) (*types.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserByID", ctx, id) + ret0, _ := ret[0].(*types.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserByID indicates an expected call of GetUserByID. +func (mr *MockManagerMockRecorder) GetUserByID(ctx, id interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByID", reflect.TypeOf((*MockManager)(nil).GetUserByID), ctx, id) +} + +// GetUserFromUserAuth mocks base method. +func (m *MockManager) GetUserFromUserAuth(ctx context.Context, userAuth auth.UserAuth) (*types.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserFromUserAuth", ctx, userAuth) + ret0, _ := ret[0].(*types.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserFromUserAuth indicates an expected call of GetUserFromUserAuth. +func (mr *MockManagerMockRecorder) GetUserFromUserAuth(ctx, userAuth interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserFromUserAuth", reflect.TypeOf((*MockManager)(nil).GetUserFromUserAuth), ctx, userAuth) +} + +// GetUserIDByPeerKey mocks base method. +func (m *MockManager) GetUserIDByPeerKey(ctx context.Context, peerKey string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserIDByPeerKey", ctx, peerKey) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserIDByPeerKey indicates an expected call of GetUserIDByPeerKey. +func (mr *MockManagerMockRecorder) GetUserIDByPeerKey(ctx, peerKey interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserIDByPeerKey", reflect.TypeOf((*MockManager)(nil).GetUserIDByPeerKey), ctx, peerKey) +} + +// GetUserInviteInfo mocks base method. +func (m *MockManager) GetUserInviteInfo(ctx context.Context, token string) (*types.UserInviteInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserInviteInfo", ctx, token) + ret0, _ := ret[0].(*types.UserInviteInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserInviteInfo indicates an expected call of GetUserInviteInfo. +func (mr *MockManagerMockRecorder) GetUserInviteInfo(ctx, token interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserInviteInfo", reflect.TypeOf((*MockManager)(nil).GetUserInviteInfo), ctx, token) +} + +// GetUsersFromAccount mocks base method. +func (m *MockManager) GetUsersFromAccount(ctx context.Context, accountID, userID string) (map[string]*types.UserInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUsersFromAccount", ctx, accountID, userID) + ret0, _ := ret[0].(map[string]*types.UserInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUsersFromAccount indicates an expected call of GetUsersFromAccount. +func (mr *MockManagerMockRecorder) GetUsersFromAccount(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsersFromAccount", reflect.TypeOf((*MockManager)(nil).GetUsersFromAccount), ctx, accountID, userID) +} + +// GetValidatedPeers mocks base method. +func (m *MockManager) GetValidatedPeers(ctx context.Context, accountID string) (map[string]struct{}, map[string]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetValidatedPeers", ctx, accountID) + ret0, _ := ret[0].(map[string]struct{}) + ret1, _ := ret[1].(map[string]string) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetValidatedPeers indicates an expected call of GetValidatedPeers. +func (mr *MockManagerMockRecorder) GetValidatedPeers(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetValidatedPeers", reflect.TypeOf((*MockManager)(nil).GetValidatedPeers), ctx, accountID) +} + +// GroupAddPeer mocks base method. +func (m *MockManager) GroupAddPeer(ctx context.Context, accountId, groupID, peerID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GroupAddPeer", ctx, accountId, groupID, peerID) + ret0, _ := ret[0].(error) + return ret0 +} + +// GroupAddPeer indicates an expected call of GroupAddPeer. +func (mr *MockManagerMockRecorder) GroupAddPeer(ctx, accountId, groupID, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GroupAddPeer", reflect.TypeOf((*MockManager)(nil).GroupAddPeer), ctx, accountId, groupID, peerID) +} + +// GroupDeletePeer mocks base method. +func (m *MockManager) GroupDeletePeer(ctx context.Context, accountId, groupID, peerID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GroupDeletePeer", ctx, accountId, groupID, peerID) + ret0, _ := ret[0].(error) + return ret0 +} + +// GroupDeletePeer indicates an expected call of GroupDeletePeer. +func (mr *MockManagerMockRecorder) GroupDeletePeer(ctx, accountId, groupID, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GroupDeletePeer", reflect.TypeOf((*MockManager)(nil).GroupDeletePeer), ctx, accountId, groupID, peerID) +} + +// GroupValidation mocks base method. +func (m *MockManager) GroupValidation(ctx context.Context, accountId string, groups []string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GroupValidation", ctx, accountId, groups) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GroupValidation indicates an expected call of GroupValidation. +func (mr *MockManagerMockRecorder) GroupValidation(ctx, accountId, groups interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GroupValidation", reflect.TypeOf((*MockManager)(nil).GroupValidation), ctx, accountId, groups) +} + +// InviteUser mocks base method. +func (m *MockManager) InviteUser(ctx context.Context, accountID, initiatorUserID, targetUserID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InviteUser", ctx, accountID, initiatorUserID, targetUserID) + ret0, _ := ret[0].(error) + return ret0 +} + +// InviteUser indicates an expected call of InviteUser. +func (mr *MockManagerMockRecorder) InviteUser(ctx, accountID, initiatorUserID, targetUserID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InviteUser", reflect.TypeOf((*MockManager)(nil).InviteUser), ctx, accountID, initiatorUserID, targetUserID) +} + +// ListNameServerGroups mocks base method. +func (m *MockManager) ListNameServerGroups(ctx context.Context, accountID, userID string) ([]*dns.NameServerGroup, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListNameServerGroups", ctx, accountID, userID) + ret0, _ := ret[0].([]*dns.NameServerGroup) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListNameServerGroups indicates an expected call of ListNameServerGroups. +func (mr *MockManagerMockRecorder) ListNameServerGroups(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListNameServerGroups", reflect.TypeOf((*MockManager)(nil).ListNameServerGroups), ctx, accountID, userID) +} + +// ListPolicies mocks base method. +func (m *MockManager) ListPolicies(ctx context.Context, accountID, userID string) ([]*types.Policy, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListPolicies", ctx, accountID, userID) + ret0, _ := ret[0].([]*types.Policy) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListPolicies indicates an expected call of ListPolicies. +func (mr *MockManagerMockRecorder) ListPolicies(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListPolicies", reflect.TypeOf((*MockManager)(nil).ListPolicies), ctx, accountID, userID) +} + +// ListPostureChecks mocks base method. +func (m *MockManager) ListPostureChecks(ctx context.Context, accountID, userID string) ([]*posture.Checks, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListPostureChecks", ctx, accountID, userID) + ret0, _ := ret[0].([]*posture.Checks) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListPostureChecks indicates an expected call of ListPostureChecks. +func (mr *MockManagerMockRecorder) ListPostureChecks(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListPostureChecks", reflect.TypeOf((*MockManager)(nil).ListPostureChecks), ctx, accountID, userID) +} + +// ListRoutes mocks base method. +func (m *MockManager) ListRoutes(ctx context.Context, accountID, userID string) ([]*route.Route, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListRoutes", ctx, accountID, userID) + ret0, _ := ret[0].([]*route.Route) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListRoutes indicates an expected call of ListRoutes. +func (mr *MockManagerMockRecorder) ListRoutes(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListRoutes", reflect.TypeOf((*MockManager)(nil).ListRoutes), ctx, accountID, userID) +} + +// ListSetupKeys mocks base method. +func (m *MockManager) ListSetupKeys(ctx context.Context, accountID, userID string) ([]*types.SetupKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListSetupKeys", ctx, accountID, userID) + ret0, _ := ret[0].([]*types.SetupKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListSetupKeys indicates an expected call of ListSetupKeys. +func (mr *MockManagerMockRecorder) ListSetupKeys(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListSetupKeys", reflect.TypeOf((*MockManager)(nil).ListSetupKeys), ctx, accountID, userID) +} + +// ListUserInvites mocks base method. +func (m *MockManager) ListUserInvites(ctx context.Context, accountID, initiatorUserID string) ([]*types.UserInvite, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListUserInvites", ctx, accountID, initiatorUserID) + ret0, _ := ret[0].([]*types.UserInvite) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListUserInvites indicates an expected call of ListUserInvites. +func (mr *MockManagerMockRecorder) ListUserInvites(ctx, accountID, initiatorUserID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListUserInvites", reflect.TypeOf((*MockManager)(nil).ListUserInvites), ctx, accountID, initiatorUserID) +} + +// ListUsers mocks base method. +func (m *MockManager) ListUsers(ctx context.Context, accountID string) ([]*types.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListUsers", ctx, accountID) + ret0, _ := ret[0].([]*types.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListUsers indicates an expected call of ListUsers. +func (mr *MockManagerMockRecorder) ListUsers(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListUsers", reflect.TypeOf((*MockManager)(nil).ListUsers), ctx, accountID) +} + +// LoginPeer mocks base method. +func (m *MockManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*peer.Peer, *types.NetworkMap, []*posture.Checks, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LoginPeer", ctx, login) + ret0, _ := ret[0].(*peer.Peer) + ret1, _ := ret[1].(*types.NetworkMap) + ret2, _ := ret[2].([]*posture.Checks) + ret3, _ := ret[3].(error) + return ret0, ret1, ret2, ret3 +} + +// LoginPeer indicates an expected call of LoginPeer. +func (mr *MockManagerMockRecorder) LoginPeer(ctx, login interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoginPeer", reflect.TypeOf((*MockManager)(nil).LoginPeer), ctx, login) +} + +// MarkPeerConnected mocks base method. +func (m *MockManager) MarkPeerConnected(ctx context.Context, peerKey string, connected bool, realIP net.IP, accountID string, syncTime time.Time) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MarkPeerConnected", ctx, peerKey, connected, realIP, accountID, syncTime) + ret0, _ := ret[0].(error) + return ret0 +} + +// MarkPeerConnected indicates an expected call of MarkPeerConnected. +func (mr *MockManagerMockRecorder) MarkPeerConnected(ctx, peerKey, connected, realIP, accountID, syncTime interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPeerConnected", reflect.TypeOf((*MockManager)(nil).MarkPeerConnected), ctx, peerKey, connected, realIP, accountID, syncTime) +} + +// OnPeerDisconnected mocks base method. +func (m *MockManager) OnPeerDisconnected(ctx context.Context, accountID, peerPubKey string, streamStartTime time.Time) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "OnPeerDisconnected", ctx, accountID, peerPubKey, streamStartTime) + ret0, _ := ret[0].(error) + return ret0 +} + +// OnPeerDisconnected indicates an expected call of OnPeerDisconnected. +func (mr *MockManagerMockRecorder) OnPeerDisconnected(ctx, accountID, peerPubKey, streamStartTime interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeerDisconnected", reflect.TypeOf((*MockManager)(nil).OnPeerDisconnected), ctx, accountID, peerPubKey, streamStartTime) +} + +// RegenerateUserInvite mocks base method. +func (m *MockManager) RegenerateUserInvite(ctx context.Context, accountID, initiatorUserID, inviteID string, expiresIn int) (*types.UserInvite, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RegenerateUserInvite", ctx, accountID, initiatorUserID, inviteID, expiresIn) + ret0, _ := ret[0].(*types.UserInvite) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RegenerateUserInvite indicates an expected call of RegenerateUserInvite. +func (mr *MockManagerMockRecorder) RegenerateUserInvite(ctx, accountID, initiatorUserID, inviteID, expiresIn interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegenerateUserInvite", reflect.TypeOf((*MockManager)(nil).RegenerateUserInvite), ctx, accountID, initiatorUserID, inviteID, expiresIn) +} + +// RejectUser mocks base method. +func (m *MockManager) RejectUser(ctx context.Context, accountID, initiatorUserID, targetUserID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RejectUser", ctx, accountID, initiatorUserID, targetUserID) + ret0, _ := ret[0].(error) + return ret0 +} + +// RejectUser indicates an expected call of RejectUser. +func (mr *MockManagerMockRecorder) RejectUser(ctx, accountID, initiatorUserID, targetUserID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RejectUser", reflect.TypeOf((*MockManager)(nil).RejectUser), ctx, accountID, initiatorUserID, targetUserID) +} + +// SaveDNSSettings mocks base method. +func (m *MockManager) SaveDNSSettings(ctx context.Context, accountID, userID string, dnsSettingsToSave *types.DNSSettings) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveDNSSettings", ctx, accountID, userID, dnsSettingsToSave) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveDNSSettings indicates an expected call of SaveDNSSettings. +func (mr *MockManagerMockRecorder) SaveDNSSettings(ctx, accountID, userID, dnsSettingsToSave interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveDNSSettings", reflect.TypeOf((*MockManager)(nil).SaveDNSSettings), ctx, accountID, userID, dnsSettingsToSave) +} + +// SaveNameServerGroup mocks base method. +func (m *MockManager) SaveNameServerGroup(ctx context.Context, accountID, userID string, nsGroupToSave *dns.NameServerGroup) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveNameServerGroup", ctx, accountID, userID, nsGroupToSave) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveNameServerGroup indicates an expected call of SaveNameServerGroup. +func (mr *MockManagerMockRecorder) SaveNameServerGroup(ctx, accountID, userID, nsGroupToSave interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveNameServerGroup", reflect.TypeOf((*MockManager)(nil).SaveNameServerGroup), ctx, accountID, userID, nsGroupToSave) +} + +// SaveOrAddUser mocks base method. +func (m *MockManager) SaveOrAddUser(ctx context.Context, accountID, initiatorUserID string, update *types.User, addIfNotExists bool) (*types.UserInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveOrAddUser", ctx, accountID, initiatorUserID, update, addIfNotExists) + ret0, _ := ret[0].(*types.UserInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SaveOrAddUser indicates an expected call of SaveOrAddUser. +func (mr *MockManagerMockRecorder) SaveOrAddUser(ctx, accountID, initiatorUserID, update, addIfNotExists interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveOrAddUser", reflect.TypeOf((*MockManager)(nil).SaveOrAddUser), ctx, accountID, initiatorUserID, update, addIfNotExists) +} + +// SaveOrAddUsers mocks base method. +func (m *MockManager) SaveOrAddUsers(ctx context.Context, accountID, initiatorUserID string, updates []*types.User, addIfNotExists bool) ([]*types.UserInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveOrAddUsers", ctx, accountID, initiatorUserID, updates, addIfNotExists) + ret0, _ := ret[0].([]*types.UserInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SaveOrAddUsers indicates an expected call of SaveOrAddUsers. +func (mr *MockManagerMockRecorder) SaveOrAddUsers(ctx, accountID, initiatorUserID, updates, addIfNotExists interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveOrAddUsers", reflect.TypeOf((*MockManager)(nil).SaveOrAddUsers), ctx, accountID, initiatorUserID, updates, addIfNotExists) +} + +// SavePolicy mocks base method. +func (m *MockManager) SavePolicy(ctx context.Context, accountID, userID string, policy *types.Policy, create bool) (*types.Policy, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SavePolicy", ctx, accountID, userID, policy, create) + ret0, _ := ret[0].(*types.Policy) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SavePolicy indicates an expected call of SavePolicy. +func (mr *MockManagerMockRecorder) SavePolicy(ctx, accountID, userID, policy, create interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePolicy", reflect.TypeOf((*MockManager)(nil).SavePolicy), ctx, accountID, userID, policy, create) +} + +// SavePostureChecks mocks base method. +func (m *MockManager) SavePostureChecks(ctx context.Context, accountID, userID string, postureChecks *posture.Checks, create bool) (*posture.Checks, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SavePostureChecks", ctx, accountID, userID, postureChecks, create) + ret0, _ := ret[0].(*posture.Checks) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SavePostureChecks indicates an expected call of SavePostureChecks. +func (mr *MockManagerMockRecorder) SavePostureChecks(ctx, accountID, userID, postureChecks, create interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePostureChecks", reflect.TypeOf((*MockManager)(nil).SavePostureChecks), ctx, accountID, userID, postureChecks, create) +} + +// SaveRoute mocks base method. +func (m *MockManager) SaveRoute(ctx context.Context, accountID, userID string, route *route.Route) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveRoute", ctx, accountID, userID, route) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveRoute indicates an expected call of SaveRoute. +func (mr *MockManagerMockRecorder) SaveRoute(ctx, accountID, userID, route interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveRoute", reflect.TypeOf((*MockManager)(nil).SaveRoute), ctx, accountID, userID, route) +} + +// SaveSetupKey mocks base method. +func (m *MockManager) SaveSetupKey(ctx context.Context, accountID string, key *types.SetupKey, userID string) (*types.SetupKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveSetupKey", ctx, accountID, key, userID) + ret0, _ := ret[0].(*types.SetupKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SaveSetupKey indicates an expected call of SaveSetupKey. +func (mr *MockManagerMockRecorder) SaveSetupKey(ctx, accountID, key, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveSetupKey", reflect.TypeOf((*MockManager)(nil).SaveSetupKey), ctx, accountID, key, userID) +} + +// SaveUser mocks base method. +func (m *MockManager) SaveUser(ctx context.Context, accountID, initiatorUserID string, update *types.User) (*types.UserInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveUser", ctx, accountID, initiatorUserID, update) + ret0, _ := ret[0].(*types.UserInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SaveUser indicates an expected call of SaveUser. +func (mr *MockManagerMockRecorder) SaveUser(ctx, accountID, initiatorUserID, update interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveUser", reflect.TypeOf((*MockManager)(nil).SaveUser), ctx, accountID, initiatorUserID, update) +} + +// SetServiceManager mocks base method. +func (m *MockManager) SetServiceManager(serviceManager service.Manager) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetServiceManager", serviceManager) +} + +// SetServiceManager indicates an expected call of SetServiceManager. +func (mr *MockManagerMockRecorder) SetServiceManager(serviceManager interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetServiceManager", reflect.TypeOf((*MockManager)(nil).SetServiceManager), serviceManager) +} + +// StoreEvent mocks base method. +func (m *MockManager) StoreEvent(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "StoreEvent", ctx, initiatorID, targetID, accountID, activityID, meta) +} + +// StoreEvent indicates an expected call of StoreEvent. +func (mr *MockManagerMockRecorder) StoreEvent(ctx, initiatorID, targetID, accountID, activityID, meta interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StoreEvent", reflect.TypeOf((*MockManager)(nil).StoreEvent), ctx, initiatorID, targetID, accountID, activityID, meta) +} + +// SyncAndMarkPeer mocks base method. +func (m *MockManager) SyncAndMarkPeer(ctx context.Context, accountID, peerPubKey string, meta peer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*peer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SyncAndMarkPeer", ctx, accountID, peerPubKey, meta, realIP, syncTime) + ret0, _ := ret[0].(*peer.Peer) + ret1, _ := ret[1].(*types.NetworkMap) + ret2, _ := ret[2].([]*posture.Checks) + ret3, _ := ret[3].(int64) + ret4, _ := ret[4].(error) + return ret0, ret1, ret2, ret3, ret4 +} + +// SyncAndMarkPeer indicates an expected call of SyncAndMarkPeer. +func (mr *MockManagerMockRecorder) SyncAndMarkPeer(ctx, accountID, peerPubKey, meta, realIP, syncTime interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SyncAndMarkPeer", reflect.TypeOf((*MockManager)(nil).SyncAndMarkPeer), ctx, accountID, peerPubKey, meta, realIP, syncTime) +} + +// SyncPeer mocks base method. +func (m *MockManager) SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*peer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SyncPeer", ctx, sync, accountID) + ret0, _ := ret[0].(*peer.Peer) + ret1, _ := ret[1].(*types.NetworkMap) + ret2, _ := ret[2].([]*posture.Checks) + ret3, _ := ret[3].(int64) + ret4, _ := ret[4].(error) + return ret0, ret1, ret2, ret3, ret4 +} + +// SyncPeer indicates an expected call of SyncPeer. +func (mr *MockManagerMockRecorder) SyncPeer(ctx, sync, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SyncPeer", reflect.TypeOf((*MockManager)(nil).SyncPeer), ctx, sync, accountID) +} + +// SyncPeerMeta mocks base method. +func (m *MockManager) SyncPeerMeta(ctx context.Context, peerPubKey string, meta peer.PeerSystemMeta) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SyncPeerMeta", ctx, peerPubKey, meta) + ret0, _ := ret[0].(error) + return ret0 +} + +// SyncPeerMeta indicates an expected call of SyncPeerMeta. +func (mr *MockManagerMockRecorder) SyncPeerMeta(ctx, peerPubKey, meta interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SyncPeerMeta", reflect.TypeOf((*MockManager)(nil).SyncPeerMeta), ctx, peerPubKey, meta) +} + +// SyncUserJWTGroups mocks base method. +func (m *MockManager) SyncUserJWTGroups(ctx context.Context, userAuth auth.UserAuth) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SyncUserJWTGroups", ctx, userAuth) + ret0, _ := ret[0].(error) + return ret0 +} + +// SyncUserJWTGroups indicates an expected call of SyncUserJWTGroups. +func (mr *MockManagerMockRecorder) SyncUserJWTGroups(ctx, userAuth interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SyncUserJWTGroups", reflect.TypeOf((*MockManager)(nil).SyncUserJWTGroups), ctx, userAuth) +} + +// UpdateAccountOnboarding mocks base method. +func (m *MockManager) UpdateAccountOnboarding(ctx context.Context, accountID, userID string, newOnboarding *types.AccountOnboarding) (*types.AccountOnboarding, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateAccountOnboarding", ctx, accountID, userID, newOnboarding) + ret0, _ := ret[0].(*types.AccountOnboarding) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateAccountOnboarding indicates an expected call of UpdateAccountOnboarding. +func (mr *MockManagerMockRecorder) UpdateAccountOnboarding(ctx, accountID, userID, newOnboarding interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountOnboarding", reflect.TypeOf((*MockManager)(nil).UpdateAccountOnboarding), ctx, accountID, userID, newOnboarding) +} + +// UpdateAccountPeers mocks base method. +func (m *MockManager) UpdateAccountPeers(ctx context.Context, accountID string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "UpdateAccountPeers", ctx, accountID) +} + +// UpdateAccountPeers indicates an expected call of UpdateAccountPeers. +func (mr *MockManagerMockRecorder) UpdateAccountPeers(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountPeers", reflect.TypeOf((*MockManager)(nil).UpdateAccountPeers), ctx, accountID) +} + +// UpdateAccountSettings mocks base method. +func (m *MockManager) UpdateAccountSettings(ctx context.Context, accountID, userID string, newSettings *types.Settings) (*types.Settings, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateAccountSettings", ctx, accountID, userID, newSettings) + ret0, _ := ret[0].(*types.Settings) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateAccountSettings indicates an expected call of UpdateAccountSettings. +func (mr *MockManagerMockRecorder) UpdateAccountSettings(ctx, accountID, userID, newSettings interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountSettings", reflect.TypeOf((*MockManager)(nil).UpdateAccountSettings), ctx, accountID, userID, newSettings) +} + +// UpdateGroup mocks base method. +func (m *MockManager) UpdateGroup(ctx context.Context, accountID, userID string, group *types.Group) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateGroup", ctx, accountID, userID, group) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateGroup indicates an expected call of UpdateGroup. +func (mr *MockManagerMockRecorder) UpdateGroup(ctx, accountID, userID, group interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateGroup", reflect.TypeOf((*MockManager)(nil).UpdateGroup), ctx, accountID, userID, group) +} + +// UpdateGroups mocks base method. +func (m *MockManager) UpdateGroups(ctx context.Context, accountID, userID string, newGroups []*types.Group) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateGroups", ctx, accountID, userID, newGroups) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateGroups indicates an expected call of UpdateGroups. +func (mr *MockManagerMockRecorder) UpdateGroups(ctx, accountID, userID, newGroups interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateGroups", reflect.TypeOf((*MockManager)(nil).UpdateGroups), ctx, accountID, userID, newGroups) +} + +// UpdateIdentityProvider mocks base method. +func (m *MockManager) UpdateIdentityProvider(ctx context.Context, accountID, idpID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateIdentityProvider", ctx, accountID, idpID, userID, idp) + ret0, _ := ret[0].(*types.IdentityProvider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateIdentityProvider indicates an expected call of UpdateIdentityProvider. +func (mr *MockManagerMockRecorder) UpdateIdentityProvider(ctx, accountID, idpID, userID, idp interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateIdentityProvider", reflect.TypeOf((*MockManager)(nil).UpdateIdentityProvider), ctx, accountID, idpID, userID, idp) +} + +// UpdateIntegratedValidator mocks base method. +func (m *MockManager) UpdateIntegratedValidator(ctx context.Context, accountID, userID, validator string, groups []string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateIntegratedValidator", ctx, accountID, userID, validator, groups) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateIntegratedValidator indicates an expected call of UpdateIntegratedValidator. +func (mr *MockManagerMockRecorder) UpdateIntegratedValidator(ctx, accountID, userID, validator, groups interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateIntegratedValidator", reflect.TypeOf((*MockManager)(nil).UpdateIntegratedValidator), ctx, accountID, userID, validator, groups) +} + +// UpdatePeer mocks base method. +func (m *MockManager) UpdatePeer(ctx context.Context, accountID, userID string, p *peer.Peer) (*peer.Peer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdatePeer", ctx, accountID, userID, p) + ret0, _ := ret[0].(*peer.Peer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdatePeer indicates an expected call of UpdatePeer. +func (mr *MockManagerMockRecorder) UpdatePeer(ctx, accountID, userID, p interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePeer", reflect.TypeOf((*MockManager)(nil).UpdatePeer), ctx, accountID, userID, p) +} + +// UpdatePeerIP mocks base method. +func (m *MockManager) UpdatePeerIP(ctx context.Context, accountID, userID, peerID string, newIP netip.Addr) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdatePeerIP", ctx, accountID, userID, peerID, newIP) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdatePeerIP indicates an expected call of UpdatePeerIP. +func (mr *MockManagerMockRecorder) UpdatePeerIP(ctx, accountID, userID, peerID, newIP interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePeerIP", reflect.TypeOf((*MockManager)(nil).UpdatePeerIP), ctx, accountID, userID, peerID, newIP) +} + +// UpdateToPrimaryAccount mocks base method. +func (m *MockManager) UpdateToPrimaryAccount(ctx context.Context, accountId string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateToPrimaryAccount", ctx, accountId) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateToPrimaryAccount indicates an expected call of UpdateToPrimaryAccount. +func (mr *MockManagerMockRecorder) UpdateToPrimaryAccount(ctx, accountId interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateToPrimaryAccount", reflect.TypeOf((*MockManager)(nil).UpdateToPrimaryAccount), ctx, accountId) +} + +// UpdateUserPassword mocks base method. +func (m *MockManager) UpdateUserPassword(ctx context.Context, accountID, currentUserID, targetUserID, oldPassword, newPassword string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUserPassword", ctx, accountID, currentUserID, targetUserID, oldPassword, newPassword) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateUserPassword indicates an expected call of UpdateUserPassword. +func (mr *MockManagerMockRecorder) UpdateUserPassword(ctx, accountID, currentUserID, targetUserID, oldPassword, newPassword interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserPassword", reflect.TypeOf((*MockManager)(nil).UpdateUserPassword), ctx, accountID, currentUserID, targetUserID, oldPassword, newPassword) +} diff --git a/management/server/account_request_buffer.go b/management/server/account_request_buffer.go index fa6c45856..ac53a9fa8 100644 --- a/management/server/account_request_buffer.go +++ b/management/server/account_request_buffer.go @@ -63,11 +63,20 @@ func (ac *AccountRequestBuffer) GetAccountWithBackpressure(ctx context.Context, log.WithContext(ctx).Tracef("requesting account %s with backpressure", accountID) startTime := time.Now() - ac.getAccountRequestCh <- req - result := <-req.ResultChan - log.WithContext(ctx).Tracef("got account with backpressure after %s", time.Since(startTime)) - return result.Account, result.Err + select { + case <-ctx.Done(): + return nil, ctx.Err() + case ac.getAccountRequestCh <- req: + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case result := <-req.ResultChan: + log.WithContext(ctx).Tracef("got account with backpressure after %s", time.Since(startTime)) + return result.Account, result.Err + } } func (ac *AccountRequestBuffer) processGetAccountBatch(ctx context.Context, accountID string) { @@ -86,7 +95,14 @@ func (ac *AccountRequestBuffer) processGetAccountBatch(ctx context.Context, acco result := &AccountResult{Account: account, Err: err} for _, req := range requests { - req.ResultChan <- result + if account != nil { + // Shallow copy the account so each goroutine gets its own struct value. + // This prevents data races when callers mutate fields like Policies. + accountCopy := *account + req.ResultChan <- &AccountResult{Account: &accountCopy, Err: err} + } else { + req.ResultChan <- result + } close(req.ResultChan) } } diff --git a/management/server/account_test.go b/management/server/account_test.go index 86cc69e8b..fdec43617 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -15,10 +15,12 @@ import ( "time" "github.com/golang/mock/gomock" + "github.com/netbirdio/netbird/shared/management/status" "github.com/prometheus/client_golang/prometheus/push" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/metric/noop" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" nbdns "github.com/netbirdio/netbird/dns" @@ -27,8 +29,13 @@ import ( "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel" "github.com/netbirdio/netbird/management/internals/modules/peers" ephemeral_manager "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + reverseproxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service/manager" "github.com/netbirdio/netbird/management/internals/modules/zones" "github.com/netbirdio/netbird/management/internals/server/config" + nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" nbAccount "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/cache" @@ -1800,6 +1807,14 @@ func TestAccount_Copy(t *testing.T) { Address: "172.12.6.1/24", }, }, + Services: []*service.Service{ + { + ID: "service1", + Name: "test-service", + AccountID: "account1", + Targets: []*service.Target{}, + }, + }, NetworkMapCache: &types.NetworkMapBuilder{}, } account.InitOnce() @@ -1881,7 +1896,7 @@ func TestDefaultAccountManager_UpdatePeer_PeerLoginExpiration(t *testing.T) { accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) require.NoError(t, err, "unable to get the account") - err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), true, nil, accountID) + err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), true, nil, accountID, time.Now().UTC()) require.NoError(t, err, "unable to mark peer connected") _, err = manager.UpdateAccountSettings(context.Background(), accountID, userID, &types.Settings{ @@ -1952,7 +1967,7 @@ func TestDefaultAccountManager_MarkPeerConnected_PeerLoginExpiration(t *testing. require.NoError(t, err, "unable to get the account") // when we mark peer as connected, the peer login expiration routine should trigger - err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), true, nil, accountID) + err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), true, nil, accountID, time.Now().UTC()) require.NoError(t, err, "unable to mark peer connected") failed := waitTimeout(wg, time.Second) @@ -1961,6 +1976,82 @@ func TestDefaultAccountManager_MarkPeerConnected_PeerLoginExpiration(t *testing. } } +func TestDefaultAccountManager_OnPeerDisconnected_LastSeenCheck(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err, "unable to create account manager") + + accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) + require.NoError(t, err, "unable to create an account") + + key, err := wgtypes.GenerateKey() + require.NoError(t, err, "unable to generate WireGuard key") + peerPubKey := key.PublicKey().String() + + _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + Key: peerPubKey, + Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"}, + }, false) + require.NoError(t, err, "unable to add peer") + + t.Run("disconnect peer when streamStartTime is after LastSeen", func(t *testing.T) { + err = manager.MarkPeerConnected(context.Background(), peerPubKey, true, nil, accountID, time.Now().UTC()) + require.NoError(t, err, "unable to mark peer connected") + + peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey) + require.NoError(t, err, "unable to get peer") + require.True(t, peer.Status.Connected, "peer should be connected") + + streamStartTime := time.Now().UTC() + + err = manager.OnPeerDisconnected(context.Background(), accountID, peerPubKey, streamStartTime) + require.NoError(t, err) + + peer, err = manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey) + require.NoError(t, err) + require.False(t, peer.Status.Connected, "peer should be disconnected") + }) + + t.Run("skip disconnect when LastSeen is after streamStartTime (zombie stream protection)", func(t *testing.T) { + err = manager.MarkPeerConnected(context.Background(), peerPubKey, true, nil, accountID, time.Now().UTC()) + require.NoError(t, err, "unable to mark peer connected") + + peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey) + require.NoError(t, err) + require.True(t, peer.Status.Connected, "peer should be connected") + + streamStartTime := peer.Status.LastSeen.Add(-1 * time.Hour) + + err = manager.OnPeerDisconnected(context.Background(), accountID, peerPubKey, streamStartTime) + require.NoError(t, err) + + peer, err = manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey) + require.NoError(t, err) + require.True(t, peer.Status.Connected, + "peer should remain connected because LastSeen > streamStartTime (zombie stream protection)") + }) + + t.Run("skip stale connect when peer already has newer LastSeen (blocked goroutine protection)", func(t *testing.T) { + node2SyncTime := time.Now().UTC() + err = manager.MarkPeerConnected(context.Background(), peerPubKey, true, nil, accountID, node2SyncTime) + require.NoError(t, err, "node 2 should connect peer") + + peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey) + require.NoError(t, err) + require.True(t, peer.Status.Connected, "peer should be connected") + require.Equal(t, node2SyncTime.Unix(), peer.Status.LastSeen.Unix(), "LastSeen should be node2SyncTime") + + node1StaleSyncTime := node2SyncTime.Add(-1 * time.Minute) + err = manager.MarkPeerConnected(context.Background(), peerPubKey, true, nil, accountID, node1StaleSyncTime) + require.NoError(t, err, "stale connect should not return error") + + peer, err = manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey) + require.NoError(t, err) + require.True(t, peer.Status.Connected, "peer should still be connected") + require.Equal(t, node2SyncTime.Unix(), peer.Status.LastSeen.Unix(), + "LastSeen should NOT be overwritten by stale syncTime from blocked goroutine") + }) +} + func TestDefaultAccountManager_UpdateAccountSettings_PeerLoginExpiration(t *testing.T) { manager, _, err := createManager(t) require.NoError(t, err, "unable to create account manager") @@ -1983,7 +2074,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_PeerLoginExpiration(t *test account, err := manager.Store.GetAccount(context.Background(), accountID) require.NoError(t, err, "unable to get the account") - err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), true, nil, accountID) + err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), true, nil, accountID, time.Now().UTC()) require.NoError(t, err, "unable to mark peer connected") wg := &sync.WaitGroup{} @@ -3026,6 +3117,12 @@ func createManager(t testing.TB) (*DefaultAccountManager, *update_channel.PeersU permissionsManager := permissions.NewManager(store) peersManager := peers.NewManager(store, permissionsManager) + proxyManager := proxy.NewMockManager(ctrl) + proxyManager.EXPECT(). + CleanupStale(gomock.Any(), gomock.Any()). + Return(nil). + AnyTimes() + ctx := context.Background() updateManager := update_channel.NewPeersUpdateManager(metrics) @@ -3036,6 +3133,13 @@ func createManager(t testing.TB) (*DefaultAccountManager, *update_channel.PeersU return nil, nil, err } + proxyGrpcServer := nbgrpc.NewProxyServiceServer(nil, nil, nil, nbgrpc.ProxyOIDCConfig{}, peersManager, nil, proxyManager) + proxyController, err := proxymanager.NewGRPCController(proxyGrpcServer, noop.Meter{}) + if err != nil { + return nil, nil, err + } + manager.SetServiceManager(reverseproxymanager.NewManager(store, manager, permissionsManager, proxyController, nil)) + return manager, updateManager, nil } @@ -3176,7 +3280,7 @@ func BenchmarkSyncAndMarkPeer(b *testing.B) { b.ResetTimer() start := time.Now() for i := 0; i < b.N; i++ { - _, _, _, _, err := manager.SyncAndMarkPeer(context.Background(), account.Id, account.Peers["peer-1"].Key, nbpeer.PeerSystemMeta{Hostname: strconv.Itoa(i)}, net.IP{1, 1, 1, 1}) + _, _, _, _, err := manager.SyncAndMarkPeer(context.Background(), account.Id, account.Peers["peer-1"].Key, nbpeer.PeerSystemMeta{Hostname: strconv.Itoa(i)}, net.IP{1, 1, 1, 1}, time.Now().UTC()) assert.NoError(b, err) } @@ -3830,3 +3934,149 @@ func TestAddNewUserToDomainAccountWithoutApproval(t *testing.T) { assert.False(t, user.PendingApproval, "User should not be pending approval") assert.Equal(t, existingAccountID, user.AccountID) } + +// TestDefaultAccountManager_UpdateAccountSettings_NetworkRangeChange verifies that +// changing NetworkRange via UpdateAccountSettings does not deadlock. +// The deadlock occurs because ReloadAllServicesForAccount is called inside a DB +// transaction but uses the main store connection, which blocks on the transaction lock. +func TestDefaultAccountManager_UpdateAccountSettings_NetworkRangeChange(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err) + + accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) + require.NoError(t, err) + + ctx := context.Background() + + // Use a channel to detect if the call completes or hangs + done := make(chan error, 1) + go func() { + _, err := manager.UpdateAccountSettings(ctx, accountID, userID, &types.Settings{ + PeerLoginExpiration: time.Hour, + PeerLoginExpirationEnabled: true, + NetworkRange: netip.MustParsePrefix("10.100.0.0/16"), + Extra: &types.ExtraSettings{}, + }) + done <- err + }() + + select { + case err := <-done: + require.NoError(t, err, "UpdateAccountSettings should complete without error") + case <-time.After(10 * time.Second): + t.Fatal("UpdateAccountSettings deadlocked when changing NetworkRange") + } +} + +func TestUpdateUserAuthWithSingleMode(t *testing.T) { + t.Run("sets defaults and overrides domain from store", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT(). + GetAnyAccountID(gomock.Any()). + Return("account-1", nil) + mockStore.EXPECT(). + GetAccountDomainAndCategory(gomock.Any(), store.LockingStrengthNone, "account-1"). + Return("real-domain.com", "private", nil) + + am := &DefaultAccountManager{ + Store: mockStore, + singleAccountModeDomain: "fallback.com", + } + + userAuth := &auth.UserAuth{} + err := am.updateUserAuthWithSingleMode(context.Background(), userAuth) + require.NoError(t, err) + assert.Equal(t, "real-domain.com", userAuth.Domain) + assert.Equal(t, types.PrivateCategory, userAuth.DomainCategory) + }) + + t.Run("falls back to singleAccountModeDomain when account ID is empty", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT(). + GetAnyAccountID(gomock.Any()). + Return("", nil) + + am := &DefaultAccountManager{ + Store: mockStore, + singleAccountModeDomain: "fallback.com", + } + + userAuth := &auth.UserAuth{} + err := am.updateUserAuthWithSingleMode(context.Background(), userAuth) + require.NoError(t, err) + assert.Equal(t, "fallback.com", userAuth.Domain) + assert.Equal(t, types.PrivateCategory, userAuth.DomainCategory) + }) + + t.Run("falls back to singleAccountModeDomain on NotFound error", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT(). + GetAnyAccountID(gomock.Any()). + Return("", status.Errorf(status.NotFound, "no accounts")) + + am := &DefaultAccountManager{ + Store: mockStore, + singleAccountModeDomain: "fallback.com", + } + + userAuth := &auth.UserAuth{} + err := am.updateUserAuthWithSingleMode(context.Background(), userAuth) + require.NoError(t, err) + assert.Equal(t, "fallback.com", userAuth.Domain) + assert.Equal(t, types.PrivateCategory, userAuth.DomainCategory) + }) + + t.Run("propagates non-NotFound error from GetAnyAccountID", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT(). + GetAnyAccountID(gomock.Any()). + Return("", status.Errorf(status.Internal, "db down")) + + am := &DefaultAccountManager{ + Store: mockStore, + singleAccountModeDomain: "fallback.com", + } + + userAuth := &auth.UserAuth{} + err := am.updateUserAuthWithSingleMode(context.Background(), userAuth) + require.Error(t, err) + assert.Contains(t, err.Error(), "db down") + // Defaults should still be set before error path + assert.Equal(t, types.PrivateCategory, userAuth.DomainCategory) + }) + + t.Run("propagates error from GetAccountDomainAndCategory", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT(). + GetAnyAccountID(gomock.Any()). + Return("account-1", nil) + mockStore.EXPECT(). + GetAccountDomainAndCategory(gomock.Any(), store.LockingStrengthNone, "account-1"). + Return("", "", status.Errorf(status.Internal, "query failed")) + + am := &DefaultAccountManager{ + Store: mockStore, + singleAccountModeDomain: "fallback.com", + } + + userAuth := &auth.UserAuth{} + err := am.updateUserAuthWithSingleMode(context.Background(), userAuth) + require.Error(t, err) + assert.Contains(t, err.Error(), "query failed") + }) +} diff --git a/management/server/activity/codes.go b/management/server/activity/codes.go index e83eeb90a..ddc3e00c3 100644 --- a/management/server/activity/codes.go +++ b/management/server/activity/codes.go @@ -204,6 +204,34 @@ const ( UserInviteLinkRegenerated Activity = 106 UserInviteLinkDeleted Activity = 107 + ServiceCreated Activity = 108 + ServiceUpdated Activity = 109 + ServiceDeleted Activity = 110 + + // PeerServiceExposed indicates that a peer exposed a service via the reverse proxy + PeerServiceExposed Activity = 111 + // PeerServiceUnexposed indicates that a peer-exposed service was removed + PeerServiceUnexposed Activity = 112 + // PeerServiceExposeExpired indicates that a peer-exposed service was removed due to TTL expiration + PeerServiceExposeExpired Activity = 113 + + // AccountPeerExposeEnabled indicates that a user enabled peer expose for the account + AccountPeerExposeEnabled Activity = 114 + // AccountPeerExposeDisabled indicates that a user disabled peer expose for the account + AccountPeerExposeDisabled Activity = 115 + + // AccountAutoUpdateAlwaysEnabled indicates that a user enabled always auto-update for the account + AccountAutoUpdateAlwaysEnabled Activity = 116 + // AccountAutoUpdateAlwaysDisabled indicates that a user disabled always auto-update for the account + AccountAutoUpdateAlwaysDisabled Activity = 117 + + // DomainAdded indicates that a user added a custom domain + DomainAdded Activity = 118 + // DomainDeleted indicates that a user deleted a custom domain + DomainDeleted Activity = 119 + // DomainValidated indicates that a custom domain was validated + DomainValidated Activity = 120 + AccountDeleted Activity = 99999 ) @@ -316,6 +344,8 @@ var activityMap = map[Activity]Code{ UserCreated: {"User created", "user.create"}, AccountAutoUpdateVersionUpdated: {"Account AutoUpdate Version updated", "account.settings.auto.version.update"}, + AccountAutoUpdateAlwaysEnabled: {"Account auto-update always enabled", "account.setting.auto.update.always.enable"}, + AccountAutoUpdateAlwaysDisabled: {"Account auto-update always disabled", "account.setting.auto.update.always.disable"}, IdentityProviderCreated: {"Identity provider created", "identityprovider.create"}, IdentityProviderUpdated: {"Identity provider updated", "identityprovider.update"}, @@ -337,6 +367,21 @@ var activityMap = map[Activity]Code{ UserInviteLinkAccepted: {"User invite link accepted", "user.invite.link.accept"}, UserInviteLinkRegenerated: {"User invite link regenerated", "user.invite.link.regenerate"}, UserInviteLinkDeleted: {"User invite link deleted", "user.invite.link.delete"}, + + ServiceCreated: {"Service created", "service.create"}, + ServiceUpdated: {"Service updated", "service.update"}, + ServiceDeleted: {"Service deleted", "service.delete"}, + + PeerServiceExposed: {"Peer exposed service", "service.peer.expose"}, + PeerServiceUnexposed: {"Peer unexposed service", "service.peer.unexpose"}, + PeerServiceExposeExpired: {"Peer exposed service expired", "service.peer.expose.expire"}, + + AccountPeerExposeEnabled: {"Account peer expose enabled", "account.setting.peer.expose.enable"}, + AccountPeerExposeDisabled: {"Account peer expose disabled", "account.setting.peer.expose.disable"}, + + DomainAdded: {"Domain added", "domain.add"}, + DomainDeleted: {"Domain deleted", "domain.delete"}, + DomainValidated: {"Domain validated", "domain.validate"}, } // StringCode returns a string code of the activity diff --git a/management/server/activity/store/sql_store.go b/management/server/activity/store/sql_store.go index db614d0cd..73e8e295c 100644 --- a/management/server/activity/store/sql_store.go +++ b/management/server/activity/store/sql_store.go @@ -249,7 +249,15 @@ func initDatabase(ctx context.Context, dataDir string) (*gorm.DB, error) { switch storeEngine { case types.SqliteStoreEngine: - dialector = sqlite.Open(filepath.Join(dataDir, eventSinkDB)) + dbFile := eventSinkDB + if envFile, ok := os.LookupEnv("NB_ACTIVITY_EVENT_SQLITE_FILE"); ok && envFile != "" { + dbFile = envFile + } + connStr := dbFile + if !filepath.IsAbs(dbFile) { + connStr = filepath.Join(dataDir, dbFile) + } + dialector = sqlite.Open(connStr) case types.PostgresStoreEngine: dsn, ok := os.LookupEnv(postgresDsnEnv) if !ok { diff --git a/management/server/cache/store_test.go b/management/server/cache/store_test.go index 1b64fd70d..b869170f0 100644 --- a/management/server/cache/store_test.go +++ b/management/server/cache/store_test.go @@ -7,8 +7,6 @@ import ( "github.com/eko/gocache/lib/v4/store" "github.com/redis/go-redis/v9" - "github.com/testcontainers/testcontainers-go" - testcontainersredis "github.com/testcontainers/testcontainers-go/modules/redis" "github.com/netbirdio/netbird/management/server/cache" @@ -50,7 +48,7 @@ func TestRedisStoreConnectionFailure(t *testing.T) { func TestRedisStoreConnectionSuccess(t *testing.T) { ctx := context.Background() - redisContainer, err := testcontainersredis.RunContainer(ctx, testcontainers.WithImage("redis:7")) + redisContainer, err := testcontainersredis.Run(ctx, "redis:7") if err != nil { t.Fatalf("couldn't start redis container: %s", err) } diff --git a/management/server/geolocation/geolocation.go b/management/server/geolocation/geolocation.go index c0179a1c4..30fd493e8 100644 --- a/management/server/geolocation/geolocation.go +++ b/management/server/geolocation/geolocation.go @@ -44,6 +44,12 @@ type Record struct { GeonameID uint `maxminddb:"geoname_id"` ISOCode string `maxminddb:"iso_code"` } `maxminddb:"country"` + Subdivisions []struct { + ISOCode string `maxminddb:"iso_code"` + Names struct { + En string `maxminddb:"en"` + } `maxminddb:"names"` + } `maxminddb:"subdivisions"` } type City struct { diff --git a/management/server/group.go b/management/server/group.go index 9fc8db120..326b167cf 100644 --- a/management/server/group.go +++ b/management/server/group.go @@ -425,6 +425,11 @@ func (am *DefaultAccountManager) DeleteGroups(ctx context.Context, accountID, us var groupIDsToDelete []string var deletedGroups []*types.Group + extraSettings, err := am.settingsManager.GetExtraSettings(ctx, accountID) + if err != nil { + return err + } + err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { for _, groupID := range groupIDs { group, err := transaction.GetGroupByID(ctx, store.LockingStrengthNone, accountID, groupID) @@ -433,7 +438,7 @@ func (am *DefaultAccountManager) DeleteGroups(ctx context.Context, accountID, us continue } - if err := validateDeleteGroup(ctx, transaction, group, userID); err != nil { + if err = validateDeleteGroup(ctx, transaction, group, userID, extraSettings.FlowGroups); err != nil { allErrors = errors.Join(allErrors, err) continue } @@ -621,7 +626,7 @@ func validateNewGroup(ctx context.Context, transaction store.Store, accountID st return nil } -func validateDeleteGroup(ctx context.Context, transaction store.Store, group *types.Group, userID string) error { +func validateDeleteGroup(ctx context.Context, transaction store.Store, group *types.Group, userID string, flowGroups []string) error { // disable a deleting integration group if the initiator is not an admin service user if group.Issued == types.GroupIssuedIntegration { executingUser, err := transaction.GetUserByUserID(ctx, store.LockingStrengthNone, userID) @@ -641,6 +646,10 @@ func validateDeleteGroup(ctx context.Context, transaction store.Store, group *ty return &GroupLinkError{"network resource", group.Resources[0].ID} } + if slices.Contains(flowGroups, group.ID) { + return &GroupLinkError{"settings", "traffic event logging"} + } + if isLinked, linkedRoute := isGroupLinkedToRoute(ctx, transaction, group.AccountID, group.ID); isLinked { return &GroupLinkError{"route", string(linkedRoute.NetID)} } diff --git a/management/server/group_test.go b/management/server/group_test.go index f7cc8d60c..fa818e532 100644 --- a/management/server/group_test.go +++ b/management/server/group_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "github.com/golang/mock/gomock" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -26,6 +27,7 @@ import ( networkTypes "github.com/netbirdio/netbird/management/server/networks/types" peer2 "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/settings" "github.com/netbirdio/netbird/management/server/store" "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/route" @@ -284,6 +286,67 @@ func TestDefaultAccountManager_DeleteGroups(t *testing.T) { } } +func TestDefaultAccountManager_DeleteGroupLinkedToFlowGroup(t *testing.T) { + am, _, err := createManager(t) + require.NoError(t, err) + + ctrl := gomock.NewController(t) + settingsMock := settings.NewMockManager(ctrl) + settingsMock.EXPECT(). + GetExtraSettings(gomock.Any(), gomock.Any()). + Return(&types.ExtraSettings{FlowGroups: []string{"grp-for-flow"}}, nil). + AnyTimes() + settingsMock.EXPECT(). + UpdateExtraSettings(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(false, nil). + AnyTimes() + am.settingsManager = settingsMock + + _, account, err := initTestGroupAccount(am) + require.NoError(t, err) + + grp := &types.Group{ + ID: "grp-for-flow", + AccountID: account.Id, + Name: "Group for flow", + Issued: types.GroupIssuedAPI, + Peers: make([]string, 0), + } + require.NoError(t, am.CreateGroup(context.Background(), account.Id, groupAdminUserID, grp)) + + err = am.DeleteGroup(context.Background(), account.Id, groupAdminUserID, "grp-for-flow") + require.Error(t, err) + + var gErr *GroupLinkError + require.ErrorAs(t, err, &gErr) + assert.Equal(t, "settings", gErr.Resource) + assert.Equal(t, "traffic event logging", gErr.Name) + + group, err := am.GetGroup(context.Background(), account.Id, "grp-for-flow", groupAdminUserID) + require.NoError(t, err) + assert.NotNil(t, group) + + regularGrp := &types.Group{ + ID: "grp-regular", + AccountID: account.Id, + Name: "Regular group", + Issued: types.GroupIssuedAPI, + Peers: make([]string, 0), + } + err = am.CreateGroup(context.Background(), account.Id, groupAdminUserID, regularGrp) + require.NoError(t, err) + + err = am.DeleteGroups(context.Background(), account.Id, groupAdminUserID, []string{"grp-for-flow", "grp-regular"}) + require.Error(t, err) + + group, err = am.GetGroup(context.Background(), account.Id, "grp-for-flow", groupAdminUserID) + require.NoError(t, err) + assert.NotNil(t, group) + + _, err = am.GetGroup(context.Background(), account.Id, "grp-regular", groupAdminUserID) + assert.Error(t, err) +} + func initTestGroupAccount(am *DefaultAccountManager) (*DefaultAccountManager, *types.Account, error) { accountID := "testingAcc" domain := "example.com" @@ -703,7 +766,7 @@ func TestGroupAccountPeersUpdate(t *testing.T) { t.Run("saving group linked to network router", func(t *testing.T) { permissionsManager := permissions.NewManager(manager.Store) groupsManager := groups.NewManager(manager.Store, permissionsManager, manager) - resourcesManager := resources.NewManager(manager.Store, permissionsManager, groupsManager, manager) + resourcesManager := resources.NewManager(manager.Store, permissionsManager, groupsManager, manager, manager.serviceManager) routersManager := routers.NewManager(manager.Store, permissionsManager, manager) networksManager := networks.NewManager(manager.Store, permissionsManager, resourcesManager, routersManager, manager) diff --git a/management/server/http/handler.go b/management/server/http/handler.go index 32a97ff44..ad36b9d46 100644 --- a/management/server/http/handler.go +++ b/management/server/http/handler.go @@ -4,16 +4,28 @@ import ( "context" "fmt" "net/http" + "net/netip" "os" "strconv" "time" "github.com/gorilla/mux" - idpmanager "github.com/netbirdio/netbird/management/server/idp" "github.com/rs/cors" log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain/manager" + + "github.com/netbirdio/netbird/management/server/types" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + reverseproxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service/manager" + + nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + idpmanager "github.com/netbirdio/netbird/management/server/idp" + "github.com/netbirdio/management-integrations/integrations" + "github.com/netbirdio/netbird/management/internals/controllers/network_map" "github.com/netbirdio/netbird/management/internals/modules/zones" zonesManager "github.com/netbirdio/netbird/management/internals/modules/zones/manager" @@ -25,6 +37,8 @@ import ( "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/http/handlers/proxy" + nbpeers "github.com/netbirdio/netbird/management/internals/modules/peers" "github.com/netbirdio/netbird/management/server/auth" "github.com/netbirdio/netbird/management/server/geolocation" @@ -59,7 +73,7 @@ const ( ) // NewAPIHandler creates the Management service HTTP API handler registering all the available endpoints. -func NewAPIHandler(ctx context.Context, accountManager account.Manager, networksManager nbnetworks.Manager, resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, authManager auth.Manager, appMetrics telemetry.AppMetrics, integratedValidator integrated_validator.IntegratedValidator, proxyController port_forwarding.Controller, permissionsManager permissions.Manager, peersManager nbpeers.Manager, settingsManager settings.Manager, zManager zones.Manager, rManager records.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager) (http.Handler, error) { +func NewAPIHandler(ctx context.Context, accountManager account.Manager, networksManager nbnetworks.Manager, resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, authManager auth.Manager, appMetrics telemetry.AppMetrics, integratedValidator integrated_validator.IntegratedValidator, proxyController port_forwarding.Controller, permissionsManager permissions.Manager, peersManager nbpeers.Manager, settingsManager settings.Manager, zManager zones.Manager, rManager records.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager, serviceManager service.Manager, reverseProxyDomainManager *manager.Manager, reverseProxyAccessLogsManager accesslogs.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer, trustedHTTPProxies []netip.Prefix) (http.Handler, error) { // Register bypass paths for unauthenticated endpoints if err := bypass.AddBypassPath("/api/instance"); err != nil { @@ -75,6 +89,10 @@ func NewAPIHandler(ctx context.Context, accountManager account.Manager, networks if err := bypass.AddBypassPath("/api/users/invites/nbi_*/accept"); err != nil { return nil, fmt.Errorf("failed to add bypass path: %w", err) } + // OAuth callback for proxy authentication + if err := bypass.AddBypassPath(types.ProxyCallbackEndpointFull); err != nil { + return nil, fmt.Errorf("failed to add bypass path: %w", err) + } var rateLimitingConfig *middleware.RateLimiterConfig if os.Getenv(rateLimitingEnabledKey) == "true" { @@ -129,15 +147,15 @@ func NewAPIHandler(ctx context.Context, accountManager account.Manager, networks return nil, fmt.Errorf("register integrations endpoints: %w", err) } - // Check if embedded IdP is enabled + // Check if embedded IdP is enabled for instance manager embeddedIdP, embeddedIdpEnabled := idpManager.(*idpmanager.EmbeddedIdPManager) instanceManager, err := nbinstance.NewManager(ctx, accountManager.GetStore(), embeddedIdP) if err != nil { return nil, fmt.Errorf("failed to create instance manager: %w", err) } - accounts.AddEndpoints(accountManager, settingsManager, embeddedIdpEnabled, router) - peers.AddEndpoints(accountManager, router, networkMapController) + accounts.AddEndpoints(accountManager, settingsManager, router) + peers.AddEndpoints(accountManager, router, networkMapController, permissionsManager) users.AddEndpoints(accountManager, router) users.AddInvitesEndpoints(accountManager, router) users.AddPublicInvitesEndpoints(accountManager, router) @@ -155,6 +173,14 @@ func NewAPIHandler(ctx context.Context, accountManager account.Manager, networks idp.AddEndpoints(accountManager, router) instance.AddEndpoints(instanceManager, router) instance.AddVersionEndpoint(instanceManager, router) + if serviceManager != nil && reverseProxyDomainManager != nil { + reverseproxymanager.RegisterEndpoints(serviceManager, *reverseProxyDomainManager, reverseProxyAccessLogsManager, permissionsManager, router) + } + // Register OAuth callback handler for proxy authentication + if proxyGRPCServer != nil { + oauthHandler := proxy.NewAuthCallbackHandler(proxyGRPCServer, trustedHTTPProxies) + oauthHandler.RegisterEndpoints(router) + } // Mount embedded IdP handler at /oauth2 path if configured if embeddedIdpEnabled { diff --git a/management/server/http/handlers/accounts/accounts_handler.go b/management/server/http/handlers/accounts/accounts_handler.go index de778d59a..cc5567e3d 100644 --- a/management/server/http/handlers/accounts/accounts_handler.go +++ b/management/server/http/handlers/accounts/accounts_handler.go @@ -36,24 +36,22 @@ const ( // handler is a handler that handles the server.Account HTTP endpoints type handler struct { - accountManager account.Manager - settingsManager settings.Manager - embeddedIdpEnabled bool + accountManager account.Manager + settingsManager settings.Manager } -func AddEndpoints(accountManager account.Manager, settingsManager settings.Manager, embeddedIdpEnabled bool, router *mux.Router) { - accountsHandler := newHandler(accountManager, settingsManager, embeddedIdpEnabled) +func AddEndpoints(accountManager account.Manager, settingsManager settings.Manager, router *mux.Router) { + accountsHandler := newHandler(accountManager, settingsManager) router.HandleFunc("/accounts/{accountId}", accountsHandler.updateAccount).Methods("PUT", "OPTIONS") router.HandleFunc("/accounts/{accountId}", accountsHandler.deleteAccount).Methods("DELETE", "OPTIONS") router.HandleFunc("/accounts", accountsHandler.getAllAccounts).Methods("GET", "OPTIONS") } // newHandler creates a new handler HTTP handler -func newHandler(accountManager account.Manager, settingsManager settings.Manager, embeddedIdpEnabled bool) *handler { +func newHandler(accountManager account.Manager, settingsManager settings.Manager) *handler { return &handler{ - accountManager: accountManager, - settingsManager: settingsManager, - embeddedIdpEnabled: embeddedIdpEnabled, + accountManager: accountManager, + settingsManager: settingsManager, } } @@ -165,11 +163,15 @@ func (h *handler) getAllAccounts(w http.ResponseWriter, r *http.Request) { return } - resp := toAccountResponse(accountID, settings, meta, onboarding, h.embeddedIdpEnabled) + resp := toAccountResponse(accountID, settings, meta, onboarding) util.WriteJSONObject(r.Context(), w, []*api.Account{resp}) } func (h *handler) updateAccountRequestSettings(req api.PutApiAccountsAccountIdJSONRequestBody) (*types.Settings, error) { + if req.Settings.PeerExposeEnabled && len(req.Settings.PeerExposeGroups) == 0 { + return nil, status.Errorf(status.InvalidArgument, "peer expose requires at least one group") + } + returnSettings := &types.Settings{ PeerLoginExpirationEnabled: req.Settings.PeerLoginExpirationEnabled, PeerLoginExpiration: time.Duration(float64(time.Second.Nanoseconds()) * float64(req.Settings.PeerLoginExpiration)), @@ -177,6 +179,9 @@ func (h *handler) updateAccountRequestSettings(req api.PutApiAccountsAccountIdJS PeerInactivityExpirationEnabled: req.Settings.PeerInactivityExpirationEnabled, PeerInactivityExpiration: time.Duration(float64(time.Second.Nanoseconds()) * float64(req.Settings.PeerInactivityExpiration)), + + PeerExposeEnabled: req.Settings.PeerExposeEnabled, + PeerExposeGroups: req.Settings.PeerExposeGroups, } if req.Settings.Extra != nil { @@ -220,6 +225,9 @@ func (h *handler) updateAccountRequestSettings(req api.PutApiAccountsAccountIdJS return nil, fmt.Errorf("invalid AutoUpdateVersion") } } + if req.Settings.AutoUpdateAlways != nil { + returnSettings.AutoUpdateAlways = *req.Settings.AutoUpdateAlways + } return returnSettings, nil } @@ -292,7 +300,7 @@ func (h *handler) updateAccount(w http.ResponseWriter, r *http.Request) { return } - resp := toAccountResponse(accountID, updatedSettings, meta, updatedOnboarding, h.embeddedIdpEnabled) + resp := toAccountResponse(accountID, updatedSettings, meta, updatedOnboarding) util.WriteJSONObject(r.Context(), w, &resp) } @@ -321,7 +329,7 @@ func (h *handler) deleteAccount(w http.ResponseWriter, r *http.Request) { util.WriteJSONObject(r.Context(), w, util.EmptyObject{}) } -func toAccountResponse(accountID string, settings *types.Settings, meta *types.AccountMeta, onboarding *types.AccountOnboarding, embeddedIdpEnabled bool) *api.Account { +func toAccountResponse(accountID string, settings *types.Settings, meta *types.AccountMeta, onboarding *types.AccountOnboarding) *api.Account { jwtAllowGroups := settings.JWTAllowGroups if jwtAllowGroups == nil { jwtAllowGroups = []string{} @@ -338,10 +346,14 @@ func toAccountResponse(accountID string, settings *types.Settings, meta *types.A JwtAllowGroups: &jwtAllowGroups, RegularUsersViewBlocked: settings.RegularUsersViewBlocked, RoutingPeerDnsResolutionEnabled: &settings.RoutingPeerDNSResolutionEnabled, + PeerExposeEnabled: settings.PeerExposeEnabled, + PeerExposeGroups: settings.PeerExposeGroups, LazyConnectionEnabled: &settings.LazyConnectionEnabled, DnsDomain: &settings.DNSDomain, AutoUpdateVersion: &settings.AutoUpdateVersion, - EmbeddedIdpEnabled: &embeddedIdpEnabled, + AutoUpdateAlways: &settings.AutoUpdateAlways, + EmbeddedIdpEnabled: &settings.EmbeddedIdpEnabled, + LocalAuthDisabled: &settings.LocalAuthDisabled, } if settings.NetworkRange.IsValid() { diff --git a/management/server/http/handlers/accounts/accounts_handler_test.go b/management/server/http/handlers/accounts/accounts_handler_test.go index e455372c8..739dfe2f6 100644 --- a/management/server/http/handlers/accounts/accounts_handler_test.go +++ b/management/server/http/handlers/accounts/accounts_handler_test.go @@ -33,7 +33,6 @@ func initAccountsTestData(t *testing.T, account *types.Account) *handler { AnyTimes() return &handler{ - embeddedIdpEnabled: false, accountManager: &mock_server.MockAccountManager{ GetAccountSettingsFunc: func(ctx context.Context, accountID string, userID string) (*types.Settings, error) { return account.Settings, nil @@ -122,8 +121,10 @@ func TestAccounts_AccountsHandler(t *testing.T) { RoutingPeerDnsResolutionEnabled: br(false), LazyConnectionEnabled: br(false), DnsDomain: sr(""), + AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), EmbeddedIdpEnabled: br(false), + LocalAuthDisabled: br(false), }, expectedArray: true, expectedID: accountID, @@ -146,8 +147,10 @@ func TestAccounts_AccountsHandler(t *testing.T) { RoutingPeerDnsResolutionEnabled: br(false), LazyConnectionEnabled: br(false), DnsDomain: sr(""), + AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), EmbeddedIdpEnabled: br(false), + LocalAuthDisabled: br(false), }, expectedArray: false, expectedID: accountID, @@ -170,8 +173,10 @@ func TestAccounts_AccountsHandler(t *testing.T) { RoutingPeerDnsResolutionEnabled: br(false), LazyConnectionEnabled: br(false), DnsDomain: sr(""), + AutoUpdateAlways: br(false), AutoUpdateVersion: sr("latest"), EmbeddedIdpEnabled: br(false), + LocalAuthDisabled: br(false), }, expectedArray: false, expectedID: accountID, @@ -194,8 +199,10 @@ func TestAccounts_AccountsHandler(t *testing.T) { RoutingPeerDnsResolutionEnabled: br(false), LazyConnectionEnabled: br(false), DnsDomain: sr(""), + AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), EmbeddedIdpEnabled: br(false), + LocalAuthDisabled: br(false), }, expectedArray: false, expectedID: accountID, @@ -218,8 +225,10 @@ func TestAccounts_AccountsHandler(t *testing.T) { RoutingPeerDnsResolutionEnabled: br(false), LazyConnectionEnabled: br(false), DnsDomain: sr(""), + AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), EmbeddedIdpEnabled: br(false), + LocalAuthDisabled: br(false), }, expectedArray: false, expectedID: accountID, @@ -242,8 +251,10 @@ func TestAccounts_AccountsHandler(t *testing.T) { RoutingPeerDnsResolutionEnabled: br(false), LazyConnectionEnabled: br(false), DnsDomain: sr(""), + AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), EmbeddedIdpEnabled: br(false), + LocalAuthDisabled: br(false), }, expectedArray: false, expectedID: accountID, diff --git a/management/server/http/handlers/instance/instance_handler.go b/management/server/http/handlers/instance/instance_handler.go index 5d8baaf8d..cd9fae6b8 100644 --- a/management/server/http/handlers/instance/instance_handler.go +++ b/management/server/http/handlers/instance/instance_handler.go @@ -46,7 +46,7 @@ func (h *handler) getInstanceStatus(w http.ResponseWriter, r *http.Request) { util.WriteErrorResponse("failed to check instance status", http.StatusInternalServerError, w) return } - + log.WithContext(r.Context()).Infof("instance setup status: %v", setupRequired) util.WriteJSONObject(r.Context(), w, api.InstanceStatus{ SetupRequired: setupRequired, }) diff --git a/management/server/http/handlers/peers/peers_handler.go b/management/server/http/handlers/peers/peers_handler.go index 53d8ab055..6b9a69f04 100644 --- a/management/server/http/handlers/peers/peers_handler.go +++ b/management/server/http/handlers/peers/peers_handler.go @@ -17,6 +17,9 @@ import ( nbcontext "github.com/netbirdio/netbird/management/server/context" "github.com/netbirdio/netbird/management/server/groups" nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/permissions/modules" + "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/shared/management/http/util" @@ -26,11 +29,12 @@ import ( // Handler is a handler that returns peers of the account type Handler struct { accountManager account.Manager + permissionsManager permissions.Manager networkMapController network_map.Controller } -func AddEndpoints(accountManager account.Manager, router *mux.Router, networkMapController network_map.Controller) { - peersHandler := NewHandler(accountManager, networkMapController) +func AddEndpoints(accountManager account.Manager, router *mux.Router, networkMapController network_map.Controller, permissionsManager permissions.Manager) { + peersHandler := NewHandler(accountManager, networkMapController, permissionsManager) router.HandleFunc("/peers", peersHandler.GetAllPeers).Methods("GET", "OPTIONS") router.HandleFunc("/peers/{peerId}", peersHandler.HandlePeer). Methods("GET", "PUT", "DELETE", "OPTIONS") @@ -42,10 +46,11 @@ func AddEndpoints(accountManager account.Manager, router *mux.Router, networkMap } // NewHandler creates a new peers Handler -func NewHandler(accountManager account.Manager, networkMapController network_map.Controller) *Handler { +func NewHandler(accountManager account.Manager, networkMapController network_map.Controller, permissionsManager permissions.Manager) *Handler { return &Handler{ accountManager: accountManager, networkMapController: networkMapController, + permissionsManager: permissionsManager, } } @@ -149,6 +154,11 @@ func (h *Handler) getPeer(ctx context.Context, accountID, peerID, userID string, return } + if peer.ProxyMeta.Embedded { + util.WriteError(ctx, status.Errorf(status.InvalidArgument, "not allowed to read peer"), w) + return + } + settings, err := h.accountManager.GetAccountSettings(ctx, accountID, activity.SystemInitiator) if err != nil { util.WriteError(ctx, err, w) @@ -316,6 +326,9 @@ func (h *Handler) GetAllPeers(w http.ResponseWriter, r *http.Request) { grpsInfoMap := groups.ToGroupsInfoMap(grps, len(peers)) respBody := make([]*api.PeerBatch, 0, len(peers)) for _, peer := range peers { + if peer.ProxyMeta.Embedded { + continue + } respBody = append(respBody, toPeerListItemResponse(peer, grpsInfoMap[peer.ID], dnsDomain, 0)) } @@ -359,21 +372,30 @@ func (h *Handler) GetAccessiblePeers(w http.ResponseWriter, r *http.Request) { return } - account, err := h.accountManager.GetAccountByID(r.Context(), accountID, activity.SystemInitiator) - if err != nil { - util.WriteError(r.Context(), err, w) - return - } - user, err := h.accountManager.GetUserByID(r.Context(), userID) if err != nil { util.WriteError(r.Context(), err, w) return } - // If the user is regular user and does not own the peer - // with the given peerID return an empty list - if !user.HasAdminPower() && !user.IsServiceUser && !userAuth.IsChild { + allowed, err := h.permissionsManager.ValidateUserPermissions(r.Context(), accountID, userID, modules.Peers, operations.Read) + if err != nil { + util.WriteError(r.Context(), status.NewPermissionValidationError(err), w) + return + } + + account, err := h.accountManager.GetAccountByID(r.Context(), accountID, activity.SystemInitiator) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + if !allowed && !userAuth.IsChild { + if account.Settings.RegularUsersViewBlocked { + util.WriteJSONObject(r.Context(), w, []api.AccessiblePeer{}) + return + } + peer, ok := account.Peers[peerID] if !ok { util.WriteError(r.Context(), status.Errorf(status.NotFound, "peer not found"), w) diff --git a/management/server/http/handlers/peers/peers_handler_test.go b/management/server/http/handlers/peers/peers_handler_test.go index 869a39b5e..6b3616597 100644 --- a/management/server/http/handlers/peers/peers_handler_test.go +++ b/management/server/http/handlers/peers/peers_handler_test.go @@ -13,13 +13,17 @@ import ( "testing" "time" + "github.com/golang/mock/gomock" "github.com/gorilla/mux" - "go.uber.org/mock/gomock" + ugomock "go.uber.org/mock/gomock" "golang.org/x/exp/maps" "github.com/netbirdio/netbird/management/internals/controllers/network_map" nbcontext "github.com/netbirdio/netbird/management/server/context" nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/permissions/modules" + "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/shared/auth" "github.com/netbirdio/netbird/shared/management/http/api" @@ -102,7 +106,7 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler { }, } - ctrl := gomock.NewController(t) + ctrl := ugomock.NewController(t) networkMapController := network_map.NewMockController(ctrl) networkMapController.EXPECT(). @@ -110,6 +114,20 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler { Return("domain"). AnyTimes() + ctrl2 := gomock.NewController(t) + permissionsManager := permissions.NewMockManager(ctrl2) + permissionsManager.EXPECT().ValidateAccountAccess(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + permissionsManager.EXPECT(). + ValidateUserPermissions(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Eq(modules.Peers), gomock.Eq(operations.Read)). + DoAndReturn(func(ctx context.Context, accountID, userID string, module modules.Module, operation operations.Operation) (bool, error) { + user, ok := account.Users[userID] + if !ok { + return false, fmt.Errorf("user not found") + } + return user.HasAdminPower() || user.IsServiceUser, nil + }). + AnyTimes() + return &Handler{ accountManager: &mock_server.MockAccountManager{ UpdatePeerFunc: func(_ context.Context, accountID, userID string, update *nbpeer.Peer) (*nbpeer.Peer, error) { @@ -199,6 +217,7 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler { }, }, networkMapController: networkMapController, + permissionsManager: permissionsManager, } } @@ -376,12 +395,11 @@ func TestGetAccessiblePeers(t *testing.T) { UserID: regularUser, } - p := initTestMetaData(t, peer1, peer2, peer3) - tt := []struct { name string peerID string callerUserID string + viewBlocked bool expectedStatus int expectedPeers []string }{ @@ -420,10 +438,56 @@ func TestGetAccessiblePeers(t *testing.T) { expectedStatus: http.StatusOK, expectedPeers: []string{"peer1", "peer2"}, }, + { + name: "regular user gets empty for owned peer list when view blocked", + peerID: "peer1", + callerUserID: regularUser, + viewBlocked: true, + expectedStatus: http.StatusOK, + expectedPeers: []string{}, + }, + { + name: "regular user gets empty list for unowned peer when view blocked", + peerID: "peer2", + callerUserID: regularUser, + viewBlocked: true, + expectedStatus: http.StatusOK, + expectedPeers: []string{}, + }, + { + name: "admin user still sees accessible peers when view blocked", + peerID: "peer2", + callerUserID: adminUser, + viewBlocked: true, + expectedStatus: http.StatusOK, + expectedPeers: []string{"peer1", "peer3"}, + }, + { + name: "service user still sees accessible peers when view blocked", + peerID: "peer3", + callerUserID: serviceUser, + viewBlocked: true, + expectedStatus: http.StatusOK, + expectedPeers: []string{"peer1", "peer2"}, + }, } for _, tc := range tt { t.Run(tc.name, func(t *testing.T) { + p := initTestMetaData(t, peer1, peer2, peer3) + + if tc.viewBlocked { + mockAM := p.accountManager.(*mock_server.MockAccountManager) + originalGetAccountByIDFunc := mockAM.GetAccountByIDFunc + mockAM.GetAccountByIDFunc = func(ctx context.Context, accountID string, userID string) (*types.Account, error) { + account, err := originalGetAccountByIDFunc(ctx, accountID, userID) + if err != nil { + return nil, err + } + account.Settings.RegularUsersViewBlocked = true + return account, nil + } + } recorder := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/peers/%s/accessible-peers", tc.peerID), nil) diff --git a/management/server/http/handlers/proxy/auth.go b/management/server/http/handlers/proxy/auth.go new file mode 100644 index 000000000..0120fad0e --- /dev/null +++ b/management/server/http/handlers/proxy/auth.go @@ -0,0 +1,208 @@ +package proxy + +import ( + "context" + "net" + "net/http" + "net/netip" + "net/url" + "strings" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/gorilla/mux" + log "github.com/sirupsen/logrus" + "golang.org/x/oauth2" + + nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + "github.com/netbirdio/netbird/management/server/http/middleware" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/proxy/auth" +) + +// AuthCallbackHandler handles OAuth callbacks for proxy authentication. +type AuthCallbackHandler struct { + proxyService *nbgrpc.ProxyServiceServer + rateLimiter *middleware.APIRateLimiter + trustedProxies []netip.Prefix +} + +// NewAuthCallbackHandler creates a new OAuth callback handler. +func NewAuthCallbackHandler(proxyService *nbgrpc.ProxyServiceServer, trustedProxies []netip.Prefix) *AuthCallbackHandler { + rateLimiterConfig := &middleware.RateLimiterConfig{ + RequestsPerMinute: 10, + Burst: 15, + CleanupInterval: 5 * time.Minute, + LimiterTTL: 10 * time.Minute, + } + + return &AuthCallbackHandler{ + proxyService: proxyService, + rateLimiter: middleware.NewAPIRateLimiter(rateLimiterConfig), + trustedProxies: trustedProxies, + } +} + +// RegisterEndpoints registers the OAuth callback endpoint. +func (h *AuthCallbackHandler) RegisterEndpoints(router *mux.Router) { + router.HandleFunc(types.ProxyCallbackEndpoint, h.handleCallback).Methods(http.MethodGet) +} + +func (h *AuthCallbackHandler) handleCallback(w http.ResponseWriter, r *http.Request) { + clientIP := h.resolveClientIP(r) + if !h.rateLimiter.Allow(clientIP) { + log.WithField("client_ip", clientIP).Warn("OAuth callback rate limit exceeded") + http.Error(w, "Too many requests. Please try again later.", http.StatusTooManyRequests) + return + } + + state := r.URL.Query().Get("state") + + codeVerifier, originalURL, err := h.proxyService.ValidateState(state) + if err != nil { + log.WithError(err).Error("OAuth callback state validation failed") + http.Error(w, "Invalid state parameter", http.StatusBadRequest) + return + } + + redirectURL, err := url.Parse(originalURL) + if err != nil { + log.WithError(err).Error("Failed to parse redirect URL") + http.Error(w, "Invalid redirect URL", http.StatusBadRequest) + return + } + + oidcConfig := h.proxyService.GetOIDCConfig() + + provider, err := oidc.NewProvider(r.Context(), oidcConfig.Issuer) + if err != nil { + log.WithError(err).Error("Failed to create OIDC provider") + http.Error(w, "Failed to create OIDC provider", http.StatusInternalServerError) + return + } + + token, err := (&oauth2.Config{ + ClientID: oidcConfig.ClientID, + Endpoint: provider.Endpoint(), + RedirectURL: oidcConfig.CallbackURL, + }).Exchange(r.Context(), r.URL.Query().Get("code"), oauth2.VerifierOption(codeVerifier)) + if err != nil { + log.WithError(err).Error("Failed to exchange code for token") + http.Error(w, "Failed to exchange code for token", http.StatusInternalServerError) + return + } + + userID := extractUserIDFromToken(r.Context(), provider, oidcConfig, token) + if userID == "" { + log.Error("Failed to extract user ID from OIDC token") + http.Error(w, "Failed to validate token", http.StatusUnauthorized) + return + } + + // Group validation is performed by the proxy via ValidateSession gRPC call. + // This allows the proxy to show 403 pages directly without redirect dance. + + sessionToken, err := h.proxyService.GenerateSessionToken(r.Context(), redirectURL.Hostname(), userID, auth.MethodOIDC) + if err != nil { + log.WithError(err).Error("Failed to create session token") + redirectURL.Scheme = "https" + query := redirectURL.Query() + query.Set("error", "access_denied") + query.Set("error_description", "Service configuration error") + redirectURL.RawQuery = query.Encode() + http.Redirect(w, r, redirectURL.String(), http.StatusFound) + return + } + + redirectURL.Scheme = "https" + + query := redirectURL.Query() + query.Set("session_token", sessionToken) + redirectURL.RawQuery = query.Encode() + + log.WithField("redirect", redirectURL.Host).Debug("OAuth callback: redirecting user with session token") + http.Redirect(w, r, redirectURL.String(), http.StatusFound) +} + +func extractUserIDFromToken(ctx context.Context, provider *oidc.Provider, config nbgrpc.ProxyOIDCConfig, token *oauth2.Token) string { + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + log.Warn("No id_token in OIDC response") + return "" + } + + verifier := provider.Verifier(&oidc.Config{ + ClientID: config.ClientID, + }) + + idToken, err := verifier.Verify(ctx, rawIDToken) + if err != nil { + log.WithError(err).Warn("Failed to verify ID token") + return "" + } + + var claims struct { + Subject string `json:"sub"` + } + if err := idToken.Claims(&claims); err != nil { + log.WithError(err).Warn("Failed to extract claims from ID token") + return "" + } + + return claims.Subject +} + +// resolveClientIP extracts the real client IP from the request. +// When trustedProxies is non-empty and the direct peer is trusted, +// it walks X-Forwarded-For right-to-left skipping trusted IPs. +// Otherwise it returns RemoteAddr directly. +func (h *AuthCallbackHandler) resolveClientIP(r *http.Request) string { + remoteIP := extractHost(r.RemoteAddr) + + if len(h.trustedProxies) == 0 || !isTrustedProxy(remoteIP, h.trustedProxies) { + return remoteIP + } + + xff := r.Header.Get("X-Forwarded-For") + if xff == "" { + return remoteIP + } + + parts := strings.Split(xff, ",") + for i := len(parts) - 1; i >= 0; i-- { + ip := strings.TrimSpace(parts[i]) + if ip == "" { + continue + } + if !isTrustedProxy(ip, h.trustedProxies) { + return ip + } + } + + // All IPs in XFF are trusted; return the leftmost as best guess. + if first := strings.TrimSpace(parts[0]); first != "" { + return first + } + return remoteIP +} + +func extractHost(remoteAddr string) string { + host, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + return remoteAddr + } + return host +} + +func isTrustedProxy(ipStr string, trusted []netip.Prefix) bool { + addr, err := netip.ParseAddr(ipStr) + if err != nil { + return false + } + for _, prefix := range trusted { + if prefix.Contains(addr) { + return true + } + } + return false +} diff --git a/management/server/http/handlers/proxy/auth_callback_integration_test.go b/management/server/http/handlers/proxy/auth_callback_integration_test.go new file mode 100644 index 000000000..3bed54e80 --- /dev/null +++ b/management/server/http/handlers/proxy/auth_callback_integration_test.go @@ -0,0 +1,559 @@ +//go:build integration + +package proxy + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/gorilla/mux" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/management/server/users" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// fakeOIDCServer creates a minimal OIDC provider for testing. +type fakeOIDCServer struct { + server *httptest.Server + issuer string + signingKey ed25519.PrivateKey + publicKey ed25519.PublicKey + keyID string + tokenSubject string + tokenExpiry time.Duration + failExchange bool +} + +func newFakeOIDCServer() *fakeOIDCServer { + pub, priv, _ := ed25519.GenerateKey(rand.Reader) + f := &fakeOIDCServer{ + signingKey: priv, + publicKey: pub, + keyID: "test-key-1", + tokenExpiry: time.Hour, + } + f.server = httptest.NewServer(f) + f.issuer = f.server.URL + return f +} + +func (f *fakeOIDCServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/openid-configuration": + f.handleDiscovery(w, r) + case "/token": + f.handleToken(w, r) + case "/keys": + f.handleJWKS(w, r) + default: + http.NotFound(w, r) + } +} + +func (f *fakeOIDCServer) handleDiscovery(w http.ResponseWriter, _ *http.Request) { + discovery := map[string]interface{}{ + "issuer": f.issuer, + "authorization_endpoint": f.issuer + "/auth", + "token_endpoint": f.issuer + "/token", + "jwks_uri": f.issuer + "/keys", + "response_types_supported": []string{ + "code", + "id_token", + "token id_token", + }, + "subject_types_supported": []string{"public"}, + "id_token_signing_alg_values_supported": []string{"EdDSA"}, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(discovery) +} + +func (f *fakeOIDCServer) handleToken(w http.ResponseWriter, r *http.Request) { + if f.failExchange { + http.Error(w, "invalid_grant", http.StatusBadRequest) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + idToken := f.createIDToken() + + response := map[string]interface{}{ + "access_token": "test-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "id_token": idToken, + "refresh_token": "test-refresh-token", + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +func (f *fakeOIDCServer) createIDToken() string { + now := time.Now() + claims := jwt.MapClaims{ + "iss": f.issuer, + "sub": f.tokenSubject, + "aud": "test-client-id", + "exp": now.Add(f.tokenExpiry).Unix(), + "iat": now.Unix(), + "nbf": now.Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) + token.Header["kid"] = f.keyID + signed, _ := token.SignedString(f.signingKey) + return signed +} + +func (f *fakeOIDCServer) handleJWKS(w http.ResponseWriter, _ *http.Request) { + jwks := map[string]interface{}{ + "keys": []map[string]interface{}{ + { + "kty": "OKP", + "crv": "Ed25519", + "kid": f.keyID, + "x": base64.RawURLEncoding.EncodeToString(f.publicKey), + "use": "sig", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(jwks) +} + +func (f *fakeOIDCServer) Close() { + f.server.Close() +} + +// testSetup contains all test dependencies. +type testSetup struct { + store store.Store + oidcServer *fakeOIDCServer + proxyService *nbgrpc.ProxyServiceServer + handler *AuthCallbackHandler + router *mux.Router + cleanup func() +} + +// testAccessLogManager is a minimal mock for accesslogs.Manager. +type testAccessLogManager struct{} + +func (m *testAccessLogManager) CleanupOldAccessLogs(ctx context.Context, retentionDays int) (int64, error) { + return 0, nil +} + +func (m *testAccessLogManager) StartPeriodicCleanup(ctx context.Context, retentionDays, cleanupIntervalHours int) { + return +} + +func (m *testAccessLogManager) StopPeriodicCleanup() { + return +} + +func (m *testAccessLogManager) SaveAccessLog(_ context.Context, _ *accesslogs.AccessLogEntry) error { + return nil +} + +func (m *testAccessLogManager) GetAllAccessLogs(_ context.Context, _, _ string, _ *accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) { + return nil, 0, nil +} + +func setupAuthCallbackTest(t *testing.T) *testSetup { + t.Helper() + + ctx := context.Background() + + testStore, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err) + + createTestAccountsAndUsers(t, ctx, testStore) + createTestReverseProxies(t, ctx, testStore) + + oidcServer := newFakeOIDCServer() + + tokenStore, err := nbgrpc.NewOneTimeTokenStore(ctx, time.Minute, 10*time.Minute, 100) + require.NoError(t, err) + + pkceStore, err := nbgrpc.NewPKCEVerifierStore(ctx, 10*time.Minute, 10*time.Minute, 100) + require.NoError(t, err) + + usersManager := users.NewManager(testStore) + + oidcConfig := nbgrpc.ProxyOIDCConfig{ + Issuer: oidcServer.issuer, + ClientID: "test-client-id", + Scopes: []string{"openid", "profile", "email"}, + CallbackURL: "https://management.example.com/reverse-proxy/callback", + HMACKey: []byte("test-hmac-key-for-state-signing"), + } + + proxyService := nbgrpc.NewProxyServiceServer( + &testAccessLogManager{}, + tokenStore, + pkceStore, + oidcConfig, + nil, + usersManager, + nil, + ) + + proxyService.SetServiceManager(&testServiceManager{store: testStore}) + + handler := NewAuthCallbackHandler(proxyService, nil) + + router := mux.NewRouter() + handler.RegisterEndpoints(router) + + return &testSetup{ + store: testStore, + oidcServer: oidcServer, + proxyService: proxyService, + handler: handler, + router: router, + cleanup: func() { + cleanup() + oidcServer.Close() + }, + } +} + +func createTestReverseProxies(t *testing.T, ctx context.Context, testStore store.Store) { + t.Helper() + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + pubKey := base64.StdEncoding.EncodeToString(pub) + privKey := base64.StdEncoding.EncodeToString(priv) + + testProxy := &service.Service{ + ID: "testProxyId", + AccountID: "testAccountId", + Name: "Test Proxy", + Domain: "test-proxy.example.com", + Targets: []*service.Target{{ + Path: strPtr("/"), + Host: "localhost", + Port: 8080, + Protocol: "http", + TargetId: "peer1", + TargetType: "peer", + Enabled: true, + }}, + Enabled: true, + Auth: service.AuthConfig{ + BearerAuth: &service.BearerAuthConfig{ + Enabled: true, + DistributionGroups: []string{"allowedGroupId"}, + }, + }, + SessionPrivateKey: privKey, + SessionPublicKey: pubKey, + } + require.NoError(t, testStore.CreateService(ctx, testProxy)) + + restrictedProxy := &service.Service{ + ID: "restrictedProxyId", + AccountID: "testAccountId", + Name: "Restricted Proxy", + Domain: "restricted-proxy.example.com", + Targets: []*service.Target{{ + Path: strPtr("/"), + Host: "localhost", + Port: 8080, + Protocol: "http", + TargetId: "peer1", + TargetType: "peer", + Enabled: true, + }}, + Enabled: true, + Auth: service.AuthConfig{ + BearerAuth: &service.BearerAuthConfig{ + Enabled: true, + DistributionGroups: []string{"restrictedGroupId"}, + }, + }, + SessionPrivateKey: privKey, + SessionPublicKey: pubKey, + } + require.NoError(t, testStore.CreateService(ctx, restrictedProxy)) + + noAuthProxy := &service.Service{ + ID: "noAuthProxyId", + AccountID: "testAccountId", + Name: "No Auth Proxy", + Domain: "no-auth-proxy.example.com", + Targets: []*service.Target{{ + Path: strPtr("/"), + Host: "localhost", + Port: 8080, + Protocol: "http", + TargetId: "peer1", + TargetType: "peer", + Enabled: true, + }}, + Enabled: true, + Auth: service.AuthConfig{ + BearerAuth: &service.BearerAuthConfig{ + Enabled: false, + }, + }, + SessionPrivateKey: privKey, + SessionPublicKey: pubKey, + } + require.NoError(t, testStore.CreateService(ctx, noAuthProxy)) +} + +func strPtr(s string) *string { + return &s +} + +func createTestAccountsAndUsers(t *testing.T, ctx context.Context, testStore store.Store) { + t.Helper() + + testAccount := &types.Account{ + Id: "testAccountId", + Domain: "test.com", + DomainCategory: "private", + IsDomainPrimaryAccount: true, + CreatedAt: time.Now(), + } + require.NoError(t, testStore.SaveAccount(ctx, testAccount)) + + allowedGroup := &types.Group{ + ID: "allowedGroupId", + AccountID: "testAccountId", + Name: "Allowed Group", + Issued: "api", + } + require.NoError(t, testStore.CreateGroup(ctx, allowedGroup)) + + allowedUser := &types.User{ + Id: "allowedUserId", + AccountID: "testAccountId", + Role: types.UserRoleUser, + AutoGroups: []string{"allowedGroupId"}, + CreatedAt: time.Now(), + Issued: "api", + } + require.NoError(t, testStore.SaveUser(ctx, allowedUser)) +} + +// testServiceManager is a minimal implementation for testing. +type testServiceManager struct { + store store.Store +} + +func (m *testServiceManager) DeleteAllServices(ctx context.Context, accountID, userID string) error { + return nil +} + +func (m *testServiceManager) GetAllServices(_ context.Context, _, _ string) ([]*service.Service, error) { + return nil, nil +} + +func (m *testServiceManager) GetService(_ context.Context, _, _, _ string) (*service.Service, error) { + return nil, nil +} + +func (m *testServiceManager) CreateService(_ context.Context, _, _ string, _ *service.Service) (*service.Service, error) { + return nil, nil +} + +func (m *testServiceManager) UpdateService(_ context.Context, _, _ string, _ *service.Service) (*service.Service, error) { + return nil, nil +} + +func (m *testServiceManager) DeleteService(_ context.Context, _, _, _ string) error { + return nil +} + +func (m *testServiceManager) SetCertificateIssuedAt(_ context.Context, _, _ string) error { + return nil +} + +func (m *testServiceManager) SetStatus(_ context.Context, _, _ string, _ service.Status) error { + return nil +} + +func (m *testServiceManager) ReloadAllServicesForAccount(_ context.Context, _ string) error { + return nil +} + +func (m *testServiceManager) ReloadService(_ context.Context, _, _ string) error { + return nil +} + +func (m *testServiceManager) GetGlobalServices(ctx context.Context) ([]*service.Service, error) { + return m.store.GetServices(ctx, store.LockingStrengthNone) +} + +func (m *testServiceManager) GetServiceByID(ctx context.Context, accountID, proxyID string) (*service.Service, error) { + return m.store.GetServiceByID(ctx, store.LockingStrengthNone, accountID, proxyID) +} + +func (m *testServiceManager) GetAccountServices(ctx context.Context, accountID string) ([]*service.Service, error) { + return m.store.GetAccountServices(ctx, store.LockingStrengthNone, accountID) +} + +func (m *testServiceManager) GetServiceIDByTargetID(_ context.Context, _, _ string) (string, error) { + return "", nil +} + +func (m *testServiceManager) CreateServiceFromPeer(_ context.Context, _, _ string, _ *service.ExposeServiceRequest) (*service.ExposeServiceResponse, error) { + return nil, nil +} + +func (m *testServiceManager) RenewServiceFromPeer(_ context.Context, _, _, _ string) error { + return nil +} + +func (m *testServiceManager) StopServiceFromPeer(_ context.Context, _, _, _ string) error { + return nil +} + +func (m *testServiceManager) StartExposeReaper(_ context.Context) {} + +func createTestState(t *testing.T, ps *nbgrpc.ProxyServiceServer, redirectURL string) string { + t.Helper() + + resp, err := ps.GetOIDCURL(context.Background(), &proto.GetOIDCURLRequest{ + RedirectUrl: redirectURL, + AccountId: "testAccountId", + }) + require.NoError(t, err) + + parsedURL, err := url.Parse(resp.Url) + require.NoError(t, err) + + return parsedURL.Query().Get("state") +} + +func TestAuthCallback_UserAllowedToLogin(t *testing.T) { + setup := setupAuthCallbackTest(t) + defer setup.cleanup() + + setup.oidcServer.tokenSubject = "allowedUserId" + + state := createTestState(t, setup.proxyService, "https://test-proxy.example.com/dashboard") + + req := httptest.NewRequest(http.MethodGet, "/reverse-proxy/callback?code=test-auth-code&state="+url.QueryEscape(state), nil) + rec := httptest.NewRecorder() + + setup.router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusFound, rec.Code) + + location := rec.Header().Get("Location") + require.NotEmpty(t, location) + + parsedLocation, err := url.Parse(location) + require.NoError(t, err) + + require.Equal(t, "test-proxy.example.com", parsedLocation.Host) + require.NotEmpty(t, parsedLocation.Query().Get("session_token"), "Should include session token") + require.Empty(t, parsedLocation.Query().Get("error"), "Should not have error parameter") +} + +func TestAuthCallback_ProxyNotFound(t *testing.T) { + setup := setupAuthCallbackTest(t) + defer setup.cleanup() + + setup.oidcServer.tokenSubject = "allowedUserId" + + state := createTestState(t, setup.proxyService, "https://test-proxy.example.com/") + + require.NoError(t, setup.store.DeleteService(context.Background(), "testAccountId", "testProxyId")) + + req := httptest.NewRequest(http.MethodGet, "/reverse-proxy/callback?code=test-auth-code&state="+url.QueryEscape(state), nil) + rec := httptest.NewRecorder() + + setup.router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusFound, rec.Code) + + location := rec.Header().Get("Location") + parsedLocation, err := url.Parse(location) + require.NoError(t, err) + + require.Equal(t, "access_denied", parsedLocation.Query().Get("error")) +} + +func TestAuthCallback_InvalidToken(t *testing.T) { + setup := setupAuthCallbackTest(t) + defer setup.cleanup() + + setup.oidcServer.failExchange = true + + state := createTestState(t, setup.proxyService, "https://test-proxy.example.com/") + + req := httptest.NewRequest(http.MethodGet, "/reverse-proxy/callback?code=invalid-code&state="+url.QueryEscape(state), nil) + rec := httptest.NewRecorder() + + setup.router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusInternalServerError, rec.Code) + require.Contains(t, rec.Body.String(), "Failed to exchange code") +} + +func TestAuthCallback_ExpiredToken(t *testing.T) { + setup := setupAuthCallbackTest(t) + defer setup.cleanup() + + setup.oidcServer.tokenSubject = "allowedUserId" + setup.oidcServer.tokenExpiry = -time.Hour + + state := createTestState(t, setup.proxyService, "https://test-proxy.example.com/") + + req := httptest.NewRequest(http.MethodGet, "/reverse-proxy/callback?code=test-auth-code&state="+url.QueryEscape(state), nil) + rec := httptest.NewRecorder() + + setup.router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusUnauthorized, rec.Code) + require.Contains(t, rec.Body.String(), "Failed to validate token") +} + +func TestAuthCallback_InvalidState(t *testing.T) { + setup := setupAuthCallbackTest(t) + defer setup.cleanup() + + req := httptest.NewRequest(http.MethodGet, "/reverse-proxy/callback?code=test-auth-code&state=invalid-state", nil) + rec := httptest.NewRecorder() + + setup.router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Contains(t, rec.Body.String(), "Invalid state") +} + +func TestAuthCallback_MissingState(t *testing.T) { + setup := setupAuthCallbackTest(t) + defer setup.cleanup() + + req := httptest.NewRequest(http.MethodGet, "/reverse-proxy/callback?code=test-auth-code", nil) + rec := httptest.NewRecorder() + + setup.router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusBadRequest, rec.Code) +} diff --git a/management/server/http/handlers/proxy/auth_test.go b/management/server/http/handlers/proxy/auth_test.go new file mode 100644 index 000000000..360405474 --- /dev/null +++ b/management/server/http/handlers/proxy/auth_test.go @@ -0,0 +1,185 @@ +package proxy + +import ( + "net/http" + "net/http/httptest" + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" +) + +func TestAuthCallbackHandler_RateLimiting(t *testing.T) { + handler := NewAuthCallbackHandler(&nbgrpc.ProxyServiceServer{}, nil) + require.NotNil(t, handler.rateLimiter, "Rate limiter should be initialized") + + req := httptest.NewRequest(http.MethodGet, "/callback?state=test&code=test", nil) + req.RemoteAddr = "192.168.1.100:12345" + + t.Run("allows requests under limit", func(t *testing.T) { + for i := 0; i < 15; i++ { + allowed := handler.rateLimiter.Allow("192.168.1.100") + assert.True(t, allowed, "Request %d should be allowed", i+1) + } + }) + + t.Run("blocks requests over limit", func(t *testing.T) { + handler.rateLimiter.Reset("192.168.1.200") + + for i := 0; i < 15; i++ { + handler.rateLimiter.Allow("192.168.1.200") + } + + allowed := handler.rateLimiter.Allow("192.168.1.200") + assert.False(t, allowed, "Request over limit should be blocked") + }) + + t.Run("different IPs have separate limits", func(t *testing.T) { + ip1 := "192.168.1.201" + ip2 := "192.168.1.202" + + handler.rateLimiter.Reset(ip1) + handler.rateLimiter.Reset(ip2) + + for i := 0; i < 15; i++ { + handler.rateLimiter.Allow(ip1) + } + + assert.False(t, handler.rateLimiter.Allow(ip1), "IP1 should be blocked") + + assert.True(t, handler.rateLimiter.Allow(ip2), "IP2 should be allowed") + }) +} + +func TestAuthCallbackHandler_RateLimitInHandleCallback(t *testing.T) { + handler := NewAuthCallbackHandler(&nbgrpc.ProxyServiceServer{}, nil) + testIP := "10.0.0.50" + + handler.rateLimiter.Reset(testIP) + + t.Run("returns 429 when rate limited", func(t *testing.T) { + for i := 0; i < 15; i++ { + handler.rateLimiter.Allow(testIP) + } + + req := httptest.NewRequest(http.MethodGet, "/callback?state=test&code=test", nil) + req.RemoteAddr = testIP + ":12345" + + rr := httptest.NewRecorder() + handler.handleCallback(rr, req) + + assert.Equal(t, http.StatusTooManyRequests, rr.Code, "Should return 429 status code") + assert.Contains(t, rr.Body.String(), "Too many requests", "Should contain rate limit message") + }) +} + +func TestResolveClientIP(t *testing.T) { + trusted := []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("172.16.0.0/12"), + } + + tests := []struct { + name string + remoteAddr string + xForwardedFor string + trustedProxy []netip.Prefix + expectedIP string + }{ + { + name: "no trusted proxies returns RemoteAddr", + remoteAddr: "203.0.113.50:9999", + xForwardedFor: "1.2.3.4", + trustedProxy: nil, + expectedIP: "203.0.113.50", + }, + { + name: "untrusted RemoteAddr ignores XFF", + remoteAddr: "203.0.113.50:9999", + xForwardedFor: "1.2.3.4, 10.0.0.1", + trustedProxy: trusted, + expectedIP: "203.0.113.50", + }, + { + name: "trusted RemoteAddr with single client in XFF", + remoteAddr: "10.0.0.1:5000", + xForwardedFor: "203.0.113.50", + trustedProxy: trusted, + expectedIP: "203.0.113.50", + }, + { + name: "trusted RemoteAddr walks past trusted entries in XFF", + remoteAddr: "10.0.0.1:5000", + xForwardedFor: "203.0.113.50, 10.0.0.2, 172.16.0.5", + trustedProxy: trusted, + expectedIP: "203.0.113.50", + }, + { + name: "trusted RemoteAddr with empty XFF falls back to RemoteAddr", + remoteAddr: "10.0.0.1:5000", + trustedProxy: trusted, + expectedIP: "10.0.0.1", + }, + { + name: "all XFF IPs trusted returns leftmost", + remoteAddr: "10.0.0.1:5000", + xForwardedFor: "10.0.0.2, 172.16.0.1, 10.0.0.3", + trustedProxy: trusted, + expectedIP: "10.0.0.2", + }, + { + name: "XFF with whitespace", + remoteAddr: "10.0.0.1:5000", + xForwardedFor: " 203.0.113.50 , 10.0.0.2 ", + trustedProxy: trusted, + expectedIP: "203.0.113.50", + }, + { + name: "multi-hop with mixed trust", + remoteAddr: "10.0.0.1:5000", + xForwardedFor: "8.8.8.8, 203.0.113.50, 172.16.0.1", + trustedProxy: trusted, + expectedIP: "203.0.113.50", + }, + { + name: "RemoteAddr without port", + remoteAddr: "192.168.1.100", + expectedIP: "192.168.1.100", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler := NewAuthCallbackHandler(&nbgrpc.ProxyServiceServer{}, tt.trustedProxy) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.RemoteAddr = tt.remoteAddr + if tt.xForwardedFor != "" { + req.Header.Set("X-Forwarded-For", tt.xForwardedFor) + } + + ip := handler.resolveClientIP(req) + assert.Equal(t, tt.expectedIP, ip) + }) + } +} + +func TestAuthCallbackHandler_RateLimiterConfiguration(t *testing.T) { + handler := NewAuthCallbackHandler(&nbgrpc.ProxyServiceServer{}, nil) + + require.NotNil(t, handler.rateLimiter, "Rate limiter should be initialized") + + testIP := "192.168.1.250" + handler.rateLimiter.Reset(testIP) + + for i := 0; i < 15; i++ { + allowed := handler.rateLimiter.Allow(testIP) + assert.True(t, allowed, "Should allow request %d within burst limit", i+1) + } + + allowed := handler.rateLimiter.Allow(testIP) + assert.False(t, allowed, "Should block request that exceeds burst limit") +} diff --git a/management/server/http/handlers/users/invites_handler_test.go b/management/server/http/handlers/users/invites_handler_test.go index 80826b9d4..529ea24d6 100644 --- a/management/server/http/handlers/users/invites_handler_test.go +++ b/management/server/http/handlers/users/invites_handler_test.go @@ -205,6 +205,14 @@ func TestCreateInvite(t *testing.T) { return nil, status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider") }, }, + { + name: "local auth disabled", + requestBody: `{"email":"test@example.com","name":"Test User","role":"user","auto_groups":[]}`, + expectedStatus: http.StatusPreconditionFailed, + mockFunc: func(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error) { + return nil, status.Errorf(status.PreconditionFailed, "local user creation is disabled - use an external identity provider") + }, + }, { name: "invalid JSON", requestBody: `{invalid json}`, @@ -376,6 +384,15 @@ func TestAcceptInvite(t *testing.T) { return status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider") }, }, + { + name: "local auth disabled", + token: testInviteToken, + requestBody: `{"password":"SecurePass123!"}`, + expectedStatus: http.StatusPreconditionFailed, + mockFunc: func(ctx context.Context, token, password string) error { + return status.Errorf(status.PreconditionFailed, "local user creation is disabled - use an external identity provider") + }, + }, { name: "missing token", token: "", diff --git a/management/server/http/middleware/auth_middleware.go b/management/server/http/middleware/auth_middleware.go index 257347153..63be672e6 100644 --- a/management/server/http/middleware/auth_middleware.go +++ b/management/server/http/middleware/auth_middleware.go @@ -11,6 +11,7 @@ import ( log "github.com/sirupsen/logrus" "go.opentelemetry.io/otel/metric" + "github.com/netbirdio/management-integrations/integrations" serverauth "github.com/netbirdio/netbird/management/server/auth" nbcontext "github.com/netbirdio/netbird/management/server/context" "github.com/netbirdio/netbird/management/server/http/middleware/bypass" @@ -130,8 +131,10 @@ func (m *AuthMiddleware) checkJWTFromRequest(r *http.Request, authHeaderParts [] } if impersonate, ok := r.URL.Query()["account"]; ok && len(impersonate) == 1 { - userAuth.AccountId = impersonate[0] - userAuth.IsChild = ok + if integrations.IsValidChildAccount(ctx, userAuth.UserId, userAuth.AccountId, impersonate[0]) { + userAuth.AccountId = impersonate[0] + userAuth.IsChild = true + } } // Email is now extracted in ToUserAuth (from claims or userinfo endpoint) @@ -207,8 +210,10 @@ func (m *AuthMiddleware) checkPATFromRequest(r *http.Request, authHeaderParts [] } if impersonate, ok := r.URL.Query()["account"]; ok && len(impersonate) == 1 { - userAuth.AccountId = impersonate[0] - userAuth.IsChild = ok + if integrations.IsValidChildAccount(r.Context(), userAuth.UserId, userAuth.AccountId, impersonate[0]) { + userAuth.AccountId = impersonate[0] + userAuth.IsChild = true + } } return nbcontext.SetUserAuthInRequest(r, userAuth), nil diff --git a/management/server/http/middleware/auth_middleware_test.go b/management/server/http/middleware/auth_middleware_test.go index 05ca59419..f397c63a4 100644 --- a/management/server/http/middleware/auth_middleware_test.go +++ b/management/server/http/middleware/auth_middleware_test.go @@ -627,15 +627,14 @@ func TestAuthMiddleware_Handler_Child(t *testing.T) { }, }, { - name: "Valid PAT Token accesses child", + name: "PAT Token with account param ignored in public version", path: "/test?account=xyz", authHeader: "Token " + PAT, expectedUserAuth: &nbauth.UserAuth{ - AccountId: "xyz", + AccountId: accountID, UserId: userID, Domain: testAccount.Domain, DomainCategory: testAccount.DomainCategory, - IsChild: true, IsPAT: true, }, }, @@ -652,15 +651,14 @@ func TestAuthMiddleware_Handler_Child(t *testing.T) { }, { - name: "Valid JWT Token with child", + name: "JWT Token with account param ignored in public version", path: "/test?account=xyz", authHeader: "Bearer " + JWT, expectedUserAuth: &nbauth.UserAuth{ - AccountId: "xyz", + AccountId: accountID, UserId: userID, Domain: testAccount.Domain, DomainCategory: testAccount.DomainCategory, - IsChild: true, }, }, } diff --git a/management/server/http/middleware/bypass/bypass.go b/management/server/http/middleware/bypass/bypass.go index 9447704cb..ddece7152 100644 --- a/management/server/http/middleware/bypass/bypass.go +++ b/management/server/http/middleware/bypass/bypass.go @@ -51,19 +51,28 @@ func GetList() []string { // This can be used to bypass authz/authn middlewares for certain paths, such as webhooks that implement their own authentication. func ShouldBypass(requestPath string, h http.Handler, w http.ResponseWriter, r *http.Request) bool { byPassMutex.RLock() - defer byPassMutex.RUnlock() - + var matched bool for bypassPath := range bypassPaths { - matched, err := path.Match(bypassPath, requestPath) + m, err := path.Match(bypassPath, requestPath) if err != nil { - log.WithContext(r.Context()).Errorf("Error matching path %s with %s from %s: %v", bypassPath, requestPath, GetList(), err) + list := make([]string, 0, len(bypassPaths)) + for k := range bypassPaths { + list = append(list, k) + } + log.WithContext(r.Context()).Errorf("Error matching path %s with %s from %v: %v", bypassPath, requestPath, list, err) continue } - if matched { - h.ServeHTTP(w, r) - return true + if m { + matched = true + break } } + byPassMutex.RUnlock() + + if matched { + h.ServeHTTP(w, r) + return true + } return false } diff --git a/management/server/http/testing/testing_tools/channel/channel.go b/management/server/http/testing/testing_tools/channel/channel.go index 9339c3541..6bd269a2c 100644 --- a/management/server/http/testing/testing_tools/channel/channel.go +++ b/management/server/http/testing/testing_tools/channel/channel.go @@ -9,8 +9,16 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/assert" + "go.opentelemetry.io/otel/metric/noop" + "github.com/netbirdio/management-integrations/integrations" + accesslogsmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs/manager" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain/manager" + proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager" + reverseproxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service/manager" + nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + zonesManager "github.com/netbirdio/netbird/management/internals/modules/zones/manager" recordsManager "github.com/netbirdio/netbird/management/internals/modules/zones/records/manager" "github.com/netbirdio/netbird/management/internals/server/config" @@ -73,7 +81,7 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee proxyController := integrations.NewController(store) userManager := users.NewManager(store) permissionsManager := permissions.NewManager(store) - settingsManager := settings.NewManager(store, userManager, integrations.NewManager(&activity.InMemoryEventStore{}), permissionsManager) + settingsManager := settings.NewManager(store, userManager, integrations.NewManager(&activity.InMemoryEventStore{}), permissionsManager, settings.IdpConfig{}) peersManager := peers.NewManager(store, permissionsManager) jobManager := job.NewJobManager(nil, store, peersManager) @@ -86,6 +94,31 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee t.Fatalf("Failed to create manager: %v", err) } + accessLogsManager := accesslogsmanager.NewManager(store, permissionsManager, nil) + proxyTokenStore, err := nbgrpc.NewOneTimeTokenStore(ctx, 5*time.Minute, 10*time.Minute, 100) + if err != nil { + t.Fatalf("Failed to create proxy token store: %v", err) + } + pkceverifierStore, err := nbgrpc.NewPKCEVerifierStore(ctx, 10*time.Minute, 10*time.Minute, 100) + if err != nil { + t.Fatalf("Failed to create PKCE verifier store: %v", err) + } + noopMeter := noop.NewMeterProvider().Meter("") + proxyMgr, err := proxymanager.NewManager(store, noopMeter) + if err != nil { + t.Fatalf("Failed to create proxy manager: %v", err) + } + proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, pkceverifierStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, proxyMgr) + domainManager := manager.NewManager(store, proxyMgr, permissionsManager, am) + serviceProxyController, err := proxymanager.NewGRPCController(proxyServiceServer, noopMeter) + if err != nil { + t.Fatalf("Failed to create proxy controller: %v", err) + } + domainManager.SetClusterCapabilities(serviceProxyController) + serviceManager := reverseproxymanager.NewManager(store, am, permissionsManager, serviceProxyController, domainManager) + proxyServiceServer.SetServiceManager(serviceManager) + am.SetServiceManager(serviceManager) + // @note this is required so that PAT's validate from store, but JWT's are mocked authManager := serverauth.NewManager(store, "", "", "", "", []string{}, false) authManagerMock := &serverauth.MockManager{ @@ -102,7 +135,7 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee customZonesManager := zonesManager.NewManager(store, am, permissionsManager, "") zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager) - apiHandler, err := http2.NewAPIHandler(context.Background(), am, networksManagerMock, resourcesManagerMock, routersManagerMock, groupsManagerMock, geoMock, authManagerMock, metrics, validatorMock, proxyController, permissionsManager, peersManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil) + apiHandler, err := http2.NewAPIHandler(context.Background(), am, networksManagerMock, resourcesManagerMock, routersManagerMock, groupsManagerMock, geoMock, authManagerMock, metrics, validatorMock, proxyController, permissionsManager, peersManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil) if err != nil { t.Fatalf("Failed to create API handler: %v", err) } diff --git a/management/server/idp/auth0.go b/management/server/idp/auth0.go index 0d4461e89..7d3837190 100644 --- a/management/server/idp/auth0.go +++ b/management/server/idp/auth0.go @@ -135,7 +135,7 @@ func NewAuth0Manager(config Auth0ClientConfig, appMetrics telemetry.AppMetrics) httpTransport := http.DefaultTransport.(*http.Transport).Clone() httpTransport.MaxIdleConns = 5 - httpClient := &http.Client{ + httpClient := &http.Client{ Timeout: idpTimeout(), Transport: httpTransport, } diff --git a/management/server/idp/authentik.go b/management/server/idp/authentik.go index 0f30cc63d..ebd79b715 100644 --- a/management/server/idp/authentik.go +++ b/management/server/idp/authentik.go @@ -56,7 +56,7 @@ func NewAuthentikManager(config AuthentikClientConfig, appMetrics telemetry.AppM Timeout: idpTimeout(), Transport: httpTransport, } - + helper := JsonParser{} if config.ClientID == "" { diff --git a/management/server/idp/azure.go b/management/server/idp/azure.go index e098424b5..320ca7a83 100644 --- a/management/server/idp/azure.go +++ b/management/server/idp/azure.go @@ -57,11 +57,11 @@ func NewAzureManager(config AzureClientConfig, appMetrics telemetry.AppMetrics) httpTransport := http.DefaultTransport.(*http.Transport).Clone() httpTransport.MaxIdleConns = 5 - httpClient := &http.Client{ + httpClient := &http.Client{ Timeout: idpTimeout(), Transport: httpTransport, } - + helper := JsonParser{} if config.ClientID == "" { diff --git a/management/server/idp/embedded.go b/management/server/idp/embedded.go index db7a91fa3..2cc7b9743 100644 --- a/management/server/idp/embedded.go +++ b/management/server/idp/embedded.go @@ -43,11 +43,16 @@ type EmbeddedIdPConfig struct { Owner *OwnerConfig // SignKeyRefreshEnabled enables automatic key rotation for signing keys SignKeyRefreshEnabled bool + // LocalAuthDisabled disables the local (email/password) authentication connector. + // When true, users cannot authenticate via email/password, only via external identity providers. + // Existing local users are preserved and will be able to login again if re-enabled. + // Cannot be enabled if no external identity provider connectors are configured. + LocalAuthDisabled bool } // EmbeddedStorageConfig holds storage configuration for the embedded IdP. type EmbeddedStorageConfig struct { - // Type is the storage type (currently only "sqlite3" is supported) + // Type is the storage type: "sqlite3" (default) or "postgres" Type string // Config contains type-specific configuration Config EmbeddedStorageTypeConfig @@ -57,6 +62,8 @@ type EmbeddedStorageConfig struct { type EmbeddedStorageTypeConfig struct { // File is the path to the SQLite database file (for sqlite3 type) File string + // DSN is the connection string for postgres + DSN string } // OwnerConfig represents the initial owner/admin user for the embedded IdP. @@ -69,6 +76,22 @@ type OwnerConfig struct { Username string } +// buildIdpStorageConfig builds the Dex storage config map based on the storage type. +func buildIdpStorageConfig(storageType string, cfg EmbeddedStorageTypeConfig) (map[string]interface{}, error) { + switch storageType { + case "sqlite3": + return map[string]interface{}{ + "file": cfg.File, + }, nil + case "postgres": + return map[string]interface{}{ + "dsn": cfg.DSN, + }, nil + default: + return nil, fmt.Errorf("unsupported IdP storage type: %s", storageType) + } +} + // ToYAMLConfig converts EmbeddedIdPConfig to dex.YAMLConfig. func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) { if c.Issuer == "" { @@ -80,19 +103,31 @@ func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) { if c.Storage.Type == "sqlite3" && c.Storage.Config.File == "" { return nil, fmt.Errorf("storage file is required for sqlite3") } + if c.Storage.Type == "postgres" && c.Storage.Config.DSN == "" { + return nil, fmt.Errorf("storage DSN is required for postgres") + } + + storageConfig, err := buildIdpStorageConfig(c.Storage.Type, c.Storage.Config) + if err != nil { + return nil, fmt.Errorf("invalid IdP storage config: %w", err) + } // Build CLI redirect URIs including the device callback (both relative and absolute) cliRedirectURIs := c.CLIRedirectURIs cliRedirectURIs = append(cliRedirectURIs, "/device/callback") cliRedirectURIs = append(cliRedirectURIs, c.Issuer+"/device/callback") + // Build dashboard redirect URIs including the OAuth callback for proxy authentication + dashboardRedirectURIs := c.DashboardRedirectURIs + baseURL := strings.TrimSuffix(c.Issuer, "/oauth2") + // todo: resolve import cycle + dashboardRedirectURIs = append(dashboardRedirectURIs, baseURL+"/api/reverse-proxy/callback") + cfg := &dex.YAMLConfig{ Issuer: c.Issuer, Storage: dex.Storage{ - Type: c.Storage.Type, - Config: map[string]interface{}{ - "file": c.Storage.Config.File, - }, + Type: c.Storage.Type, + Config: storageConfig, }, Web: dex.Web{ AllowedOrigins: []string{"*"}, @@ -105,13 +140,15 @@ func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) { Issuer: "NetBird", Theme: "light", }, + // Always enable password DB initially - we disable the local connector after startup if needed. + // This ensures Dex has at least one connector during initialization. EnablePasswordDB: true, StaticClients: []storage.Client{ { ID: staticClientDashboard, Name: "NetBird Dashboard", Public: true, - RedirectURIs: c.DashboardRedirectURIs, + RedirectURIs: dashboardRedirectURIs, }, { ID: staticClientCLI, @@ -192,11 +229,32 @@ func NewEmbeddedIdPManager(ctx context.Context, config *EmbeddedIdPConfig, appMe return nil, err } + log.WithContext(ctx).Debugf("initializing embedded Dex IDP with config: %+v", config) + provider, err := dex.NewProviderFromYAML(ctx, yamlConfig) if err != nil { return nil, fmt.Errorf("failed to create embedded IdP provider: %w", err) } + // If local auth is disabled, validate that other connectors exist + if config.LocalAuthDisabled { + hasOthers, err := provider.HasNonLocalConnectors(ctx) + if err != nil { + _ = provider.Stop(ctx) + return nil, fmt.Errorf("failed to check connectors: %w", err) + } + if !hasOthers { + _ = provider.Stop(ctx) + return nil, fmt.Errorf("cannot disable local authentication: no other identity providers configured") + } + // Ensure local connector is removed (it might exist from a previous run) + if err := provider.DisableLocalAuth(ctx); err != nil { + _ = provider.Stop(ctx) + return nil, fmt.Errorf("failed to disable local auth: %w", err) + } + log.WithContext(ctx).Info("local authentication disabled - only external identity providers can be used") + } + log.WithContext(ctx).Infof("embedded Dex IDP initialized with issuer: %s", yamlConfig.Issuer) return &EmbeddedIdPManager{ @@ -281,6 +339,8 @@ func (m *EmbeddedIdPManager) GetAllAccounts(ctx context.Context) (map[string][]* return nil, fmt.Errorf("failed to list users: %w", err) } + log.WithContext(ctx).Debugf("retrieved %d users from embedded IdP", len(users)) + indexedUsers := make(map[string][]*UserData) for _, user := range users { indexedUsers[UnsetAccountID] = append(indexedUsers[UnsetAccountID], &UserData{ @@ -290,11 +350,17 @@ func (m *EmbeddedIdPManager) GetAllAccounts(ctx context.Context) (map[string][]* }) } + log.WithContext(ctx).Debugf("retrieved %d users from embedded IdP", len(indexedUsers[UnsetAccountID])) + return indexedUsers, nil } // CreateUser creates a new user in the embedded IdP. func (m *EmbeddedIdPManager) CreateUser(ctx context.Context, email, name, accountID, invitedByEmail string) (*UserData, error) { + if m.config.LocalAuthDisabled { + return nil, fmt.Errorf("local user creation is disabled") + } + if m.appMetrics != nil { m.appMetrics.IDPMetrics().CountCreateUser() } @@ -364,6 +430,10 @@ func (m *EmbeddedIdPManager) GetUserByEmail(ctx context.Context, email string) ( // Unlike CreateUser which auto-generates a password, this method uses the provided password. // This is useful for instance setup where the user provides their own password. func (m *EmbeddedIdPManager) CreateUserWithPassword(ctx context.Context, email, password, name string) (*UserData, error) { + if m.config.LocalAuthDisabled { + return nil, fmt.Errorf("local user creation is disabled") + } + if m.appMetrics != nil { m.appMetrics.IDPMetrics().CountCreateUser() } @@ -553,3 +623,13 @@ func (m *EmbeddedIdPManager) GetClientIDs() []string { func (m *EmbeddedIdPManager) GetUserIDClaim() string { return defaultUserIDClaim } + +// IsLocalAuthDisabled returns whether local authentication is disabled based on configuration. +func (m *EmbeddedIdPManager) IsLocalAuthDisabled() bool { + return m.config.LocalAuthDisabled +} + +// HasNonLocalConnectors checks if there are any identity provider connectors other than local. +func (m *EmbeddedIdPManager) HasNonLocalConnectors(ctx context.Context) (bool, error) { + return m.provider.HasNonLocalConnectors(ctx) +} diff --git a/management/server/idp/embedded_test.go b/management/server/idp/embedded_test.go index d8d3009dd..4dda483fb 100644 --- a/management/server/idp/embedded_test.go +++ b/management/server/idp/embedded_test.go @@ -370,3 +370,234 @@ func TestEmbeddedIdPManager_GetLocalKeysLocation(t *testing.T) { }) } } + +func TestEmbeddedIdPManager_LocalAuthDisabled(t *testing.T) { + ctx := context.Background() + + t.Run("cannot start with local auth disabled without other connectors", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + config := &EmbeddedIdPConfig{ + Enabled: true, + Issuer: "http://localhost:5556/dex", + LocalAuthDisabled: true, + Storage: EmbeddedStorageConfig{ + Type: "sqlite3", + Config: EmbeddedStorageTypeConfig{ + File: filepath.Join(tmpDir, "dex.db"), + }, + }, + } + + _, err = NewEmbeddedIdPManager(ctx, config, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "no other identity providers configured") + }) + + t.Run("local auth enabled by default", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + config := &EmbeddedIdPConfig{ + Enabled: true, + Issuer: "http://localhost:5556/dex", + Storage: EmbeddedStorageConfig{ + Type: "sqlite3", + Config: EmbeddedStorageTypeConfig{ + File: filepath.Join(tmpDir, "dex.db"), + }, + }, + } + + manager, err := NewEmbeddedIdPManager(ctx, config, nil) + require.NoError(t, err) + defer func() { _ = manager.Stop(ctx) }() + + // Verify local auth is enabled by default + assert.False(t, manager.IsLocalAuthDisabled()) + }) + + t.Run("start with local auth disabled when connector exists", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + dbFile := filepath.Join(tmpDir, "dex.db") + + // First, create a manager with local auth enabled and add a connector + config1 := &EmbeddedIdPConfig{ + Enabled: true, + Issuer: "http://localhost:5556/dex", + Storage: EmbeddedStorageConfig{ + Type: "sqlite3", + Config: EmbeddedStorageTypeConfig{ + File: dbFile, + }, + }, + } + + manager1, err := NewEmbeddedIdPManager(ctx, config1, nil) + require.NoError(t, err) + + // Create a user + userData, err := manager1.CreateUser(ctx, "preserved@example.com", "Preserved User", "account1", "admin@example.com") + require.NoError(t, err) + userID := userData.ID + + // Add an external connector (Google doesn't require OIDC discovery) + _, err = manager1.CreateConnector(ctx, &dex.ConnectorConfig{ + ID: "google-test", + Name: "Google Test", + Type: "google", + ClientID: "test-client-id", + ClientSecret: "test-client-secret", + }) + require.NoError(t, err) + + // Stop the first manager + err = manager1.Stop(ctx) + require.NoError(t, err) + + // Now create a new manager with local auth disabled + config2 := &EmbeddedIdPConfig{ + Enabled: true, + Issuer: "http://localhost:5556/dex", + LocalAuthDisabled: true, + Storage: EmbeddedStorageConfig{ + Type: "sqlite3", + Config: EmbeddedStorageTypeConfig{ + File: dbFile, + }, + }, + } + + manager2, err := NewEmbeddedIdPManager(ctx, config2, nil) + require.NoError(t, err) + defer func() { _ = manager2.Stop(ctx) }() + + // Verify local auth is disabled via config + assert.True(t, manager2.IsLocalAuthDisabled()) + + // Verify the user still exists in storage (just can't login via local) + lookedUp, err := manager2.GetUserDataByID(ctx, userID, AppMetadata{}) + require.NoError(t, err) + assert.Equal(t, "preserved@example.com", lookedUp.Email) + }) + + t.Run("CreateUser fails when local auth is disabled", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + dbFile := filepath.Join(tmpDir, "dex.db") + + // First, create a manager and add an external connector + config1 := &EmbeddedIdPConfig{ + Enabled: true, + Issuer: "http://localhost:5556/dex", + Storage: EmbeddedStorageConfig{ + Type: "sqlite3", + Config: EmbeddedStorageTypeConfig{ + File: dbFile, + }, + }, + } + + manager1, err := NewEmbeddedIdPManager(ctx, config1, nil) + require.NoError(t, err) + + _, err = manager1.CreateConnector(ctx, &dex.ConnectorConfig{ + ID: "google-test", + Name: "Google Test", + Type: "google", + ClientID: "test-client-id", + ClientSecret: "test-client-secret", + }) + require.NoError(t, err) + + err = manager1.Stop(ctx) + require.NoError(t, err) + + // Create manager with local auth disabled + config2 := &EmbeddedIdPConfig{ + Enabled: true, + Issuer: "http://localhost:5556/dex", + LocalAuthDisabled: true, + Storage: EmbeddedStorageConfig{ + Type: "sqlite3", + Config: EmbeddedStorageTypeConfig{ + File: dbFile, + }, + }, + } + + manager2, err := NewEmbeddedIdPManager(ctx, config2, nil) + require.NoError(t, err) + defer func() { _ = manager2.Stop(ctx) }() + + // Try to create a user - should fail + _, err = manager2.CreateUser(ctx, "newuser@example.com", "New User", "account1", "admin@example.com") + require.Error(t, err) + assert.Contains(t, err.Error(), "local user creation is disabled") + }) + + t.Run("CreateUserWithPassword fails when local auth is disabled", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + dbFile := filepath.Join(tmpDir, "dex.db") + + // First, create a manager and add an external connector + config1 := &EmbeddedIdPConfig{ + Enabled: true, + Issuer: "http://localhost:5556/dex", + Storage: EmbeddedStorageConfig{ + Type: "sqlite3", + Config: EmbeddedStorageTypeConfig{ + File: dbFile, + }, + }, + } + + manager1, err := NewEmbeddedIdPManager(ctx, config1, nil) + require.NoError(t, err) + + _, err = manager1.CreateConnector(ctx, &dex.ConnectorConfig{ + ID: "google-test", + Name: "Google Test", + Type: "google", + ClientID: "test-client-id", + ClientSecret: "test-client-secret", + }) + require.NoError(t, err) + + err = manager1.Stop(ctx) + require.NoError(t, err) + + // Create manager with local auth disabled + config2 := &EmbeddedIdPConfig{ + Enabled: true, + Issuer: "http://localhost:5556/dex", + LocalAuthDisabled: true, + Storage: EmbeddedStorageConfig{ + Type: "sqlite3", + Config: EmbeddedStorageTypeConfig{ + File: dbFile, + }, + }, + } + + manager2, err := NewEmbeddedIdPManager(ctx, config2, nil) + require.NoError(t, err) + defer func() { _ = manager2.Stop(ctx) }() + + // Try to create a user with password - should fail + _, err = manager2.CreateUserWithPassword(ctx, "newuser@example.com", "SecurePass123!", "New User") + require.Error(t, err) + assert.Contains(t, err.Error(), "local user creation is disabled") + }) +} diff --git a/management/server/idp/google_workspace.go b/management/server/idp/google_workspace.go index 6e417d394..48e4f3000 100644 --- a/management/server/idp/google_workspace.go +++ b/management/server/idp/google_workspace.go @@ -51,7 +51,7 @@ func NewGoogleWorkspaceManager(ctx context.Context, config GoogleWorkspaceClient Timeout: idpTimeout(), Transport: httpTransport, } - + helper := JsonParser{} if config.CustomerID == "" { diff --git a/management/server/idp/keycloak.go b/management/server/idp/keycloak.go index b640f7520..1cf26394f 100644 --- a/management/server/idp/keycloak.go +++ b/management/server/idp/keycloak.go @@ -66,7 +66,7 @@ func NewKeycloakManager(config KeycloakClientConfig, appMetrics telemetry.AppMet Timeout: idpTimeout(), Transport: httpTransport, } - + helper := JsonParser{} if config.ClientID == "" { diff --git a/management/server/idp/pocketid.go b/management/server/idp/pocketid.go index ee8e304ee..fc338b86b 100644 --- a/management/server/idp/pocketid.go +++ b/management/server/idp/pocketid.go @@ -90,7 +90,7 @@ func NewPocketIdManager(config PocketIdClientConfig, appMetrics telemetry.AppMet Timeout: idpTimeout(), Transport: httpTransport, } - + helper := JsonParser{} if config.ManagementEndpoint == "" { diff --git a/management/server/idp/util.go b/management/server/idp/util.go index 4310d1388..ed82fb9e3 100644 --- a/management/server/idp/util.go +++ b/management/server/idp/util.go @@ -76,7 +76,7 @@ const ( // Provides the env variable name for use with idpTimeout function idpTimeoutEnv = "NB_IDP_TIMEOUT" // Sets the defaultTimeout to 10s. - defaultTimeout = 10 * time.Second + defaultTimeout = 10 * time.Second ) // idpTimeout returns a timeout value for the IDP diff --git a/management/server/idp/zitadel.go b/management/server/idp/zitadel.go index ea0fd0aa7..320f0c131 100644 --- a/management/server/idp/zitadel.go +++ b/management/server/idp/zitadel.go @@ -167,7 +167,7 @@ func NewZitadelManager(config ZitadelClientConfig, appMetrics telemetry.AppMetri Timeout: idpTimeout(), Transport: httpTransport, } - + helper := JsonParser{} hasPAT := config.PAT != "" diff --git a/management/server/instance/manager.go b/management/server/instance/manager.go index 6a0509ebd..19e3abdc0 100644 --- a/management/server/instance/manager.go +++ b/management/server/instance/manager.go @@ -104,13 +104,22 @@ func NewManager(ctx context.Context, store store.Store, idpManager idp.Manager) } func (m *DefaultManager) loadSetupRequired(ctx context.Context) error { + // Check if there are any accounts in the NetBird store + numAccounts, err := m.store.GetAccountsCounter(ctx) + if err != nil { + return err + } + hasAccounts := numAccounts > 0 + + // Check if there are any users in the embedded IdP (Dex) users, err := m.embeddedIdpManager.GetAllAccounts(ctx) if err != nil { return err } + hasLocalUsers := len(users) > 0 m.setupMu.Lock() - m.setupRequired = len(users) == 0 + m.setupRequired = !(hasAccounts || hasLocalUsers) m.setupMu.Unlock() return nil diff --git a/management/server/job/channel.go b/management/server/job/channel.go index c4dc98a68..c4454c4c9 100644 --- a/management/server/job/channel.go +++ b/management/server/job/channel.go @@ -28,7 +28,13 @@ func NewChannel() *Channel { return jc } -func (jc *Channel) AddEvent(ctx context.Context, responseWait time.Duration, event *Event) error { +func (jc *Channel) AddEvent(ctx context.Context, responseWait time.Duration, event *Event) (err error) { + defer func() { + if r := recover(); r != nil { + err = ErrJobChannelClosed + } + }() + select { case <-ctx.Done(): return ctx.Err() diff --git a/management/server/management_test.go b/management/server/management_test.go index 0864baadf..de02855bf 100644 --- a/management/server/management_test.go +++ b/management/server/management_test.go @@ -610,6 +610,7 @@ func TestSync10PeersGetUpdates(t *testing.T) { initialPeers := 10 additionalPeers := 10 + expectedPeerCount := initialPeers + additionalPeers - 1 // -1 because peer doesn't see itself var peers []wgtypes.Key for i := 0; i < initialPeers; i++ { @@ -618,8 +619,19 @@ func TestSync10PeersGetUpdates(t *testing.T) { peers = append(peers, key) } + // Track the maximum peer count each peer has seen + type peerState struct { + mu sync.Mutex + maxPeerCount int + done bool + } + peerStates := make(map[string]*peerState) + for _, pk := range peers { + peerStates[pk.PublicKey().String()] = &peerState{} + } + var wg sync.WaitGroup - wg.Add(initialPeers + initialPeers*additionalPeers) + wg.Add(initialPeers) // One completion per initial peer var syncClients []mgmtProto.ManagementService_SyncClient for _, pk := range peers { @@ -643,6 +655,9 @@ func TestSync10PeersGetUpdates(t *testing.T) { syncClients = append(syncClients, s) go func(pk wgtypes.Key, syncStream mgmtProto.ManagementService_SyncClient) { + pubKey := pk.PublicKey().String() + state := peerStates[pubKey] + for { encMsg := &mgmtProto.EncryptedMessage{} err := syncStream.RecvMsg(encMsg) @@ -651,19 +666,28 @@ func TestSync10PeersGetUpdates(t *testing.T) { } decryptedBytes, decErr := encryption.Decrypt(encMsg.Body, ts.serverPubKey, pk) if decErr != nil { - t.Errorf("failed to decrypt SyncResponse for peer %s: %v", pk.PublicKey().String(), decErr) + t.Errorf("failed to decrypt SyncResponse for peer %s: %v", pubKey, decErr) return } resp := &mgmtProto.SyncResponse{} umErr := pb.Unmarshal(decryptedBytes, resp) if umErr != nil { - t.Errorf("failed to unmarshal SyncResponse for peer %s: %v", pk.PublicKey().String(), umErr) + t.Errorf("failed to unmarshal SyncResponse for peer %s: %v", pubKey, umErr) return } - // We only count if there's a new peer update - if len(resp.GetRemotePeers()) > 0 { + + // Track the maximum peer count seen (due to debouncing, updates are coalesced) + peerCount := len(resp.GetRemotePeers()) + state.mu.Lock() + if peerCount > state.maxPeerCount { + state.maxPeerCount = peerCount + } + // Signal completion when this peer has seen all expected peers + if !state.done && state.maxPeerCount >= expectedPeerCount { + state.done = true wg.Done() } + state.mu.Unlock() } }(pk, s) } @@ -677,7 +701,30 @@ func TestSync10PeersGetUpdates(t *testing.T) { time.Sleep(time.Duration(n) * time.Millisecond) } - wg.Wait() + // Wait for debouncer to flush final updates (debounce interval is 1000ms) + time.Sleep(1500 * time.Millisecond) + + // Wait with timeout + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + // Success - all peers received expected peer count + case <-time.After(5 * time.Second): + // Timeout - report which peers didn't receive all updates + t.Error("Timeout waiting for all peers to receive updates") + for pubKey, state := range peerStates { + state.mu.Lock() + if state.maxPeerCount < expectedPeerCount { + t.Errorf("Peer %s only saw %d peers, expected %d", pubKey, state.maxPeerCount, expectedPeerCount) + } + state.mu.Unlock() + } + } for _, sc := range syncClients { err := sc.CloseSend() diff --git a/management/server/metrics/selfhosted.go b/management/server/metrics/selfhosted.go index f7a344fcd..8732cf89f 100644 --- a/management/server/metrics/selfhosted.go +++ b/management/server/metrics/selfhosted.go @@ -14,6 +14,7 @@ import ( "github.com/hashicorp/go-version" "github.com/netbirdio/netbird/idp/dex" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/management/server/types" @@ -51,6 +52,7 @@ type properties map[string]interface{} type DataSource interface { GetAllAccounts(ctx context.Context) []*types.Account GetStoreEngine() types.Engine + GetCustomDomainsCounts(ctx context.Context) (total int64, validated int64, err error) } // ConnManager peer connection manager that holds state for current active connections @@ -210,6 +212,17 @@ func (w *Worker) generateProperties(ctx context.Context) properties { rosenpassEnabled int localUsers int idpUsers int + embeddedIdpTypes map[string]int + services int + servicesEnabled int + servicesTargets int + servicesStatusActive int + servicesStatusPending int + servicesStatusError int + servicesTargetType map[rpservice.TargetType]int + servicesAuthPassword int + servicesAuthPin int + servicesAuthOIDC int ) start := time.Now() metricsProperties := make(properties) @@ -218,10 +231,14 @@ func (w *Worker) generateProperties(ctx context.Context) properties { rulesProtocol = make(map[string]int) rulesDirection = make(map[string]int) activeUsersLastDay = make(map[string]struct{}) + embeddedIdpTypes = make(map[string]int) + servicesTargetType = make(map[rpservice.TargetType]int) uptime = time.Since(w.startupTime).Seconds() connections := w.connManager.GetAllConnectedPeers() version = nbversion.NetbirdVersion() + customDomains, customDomainsValidated, _ := w.dataSource.GetCustomDomainsCounts(ctx) + for _, account := range w.dataSource.GetAllAccounts(ctx) { accounts++ @@ -278,6 +295,8 @@ func (w *Worker) generateProperties(ctx context.Context) properties { } else { idpUsers++ } + idpType := extractIdpType(idpID) + embeddedIdpTypes[idpType]++ } } } @@ -331,6 +350,37 @@ func (w *Worker) generateProperties(ctx context.Context) properties { peerActiveVersions = append(peerActiveVersions, peer.Meta.WtVersion) } } + + for _, service := range account.Services { + services++ + if service.Enabled { + servicesEnabled++ + } + servicesTargets += len(service.Targets) + + switch rpservice.Status(service.Meta.Status) { + case rpservice.StatusActive: + servicesStatusActive++ + case rpservice.StatusPending: + servicesStatusPending++ + case rpservice.StatusError, rpservice.StatusCertificateFailed, rpservice.StatusTunnelNotCreated: + servicesStatusError++ + } + + for _, target := range service.Targets { + servicesTargetType[target.TargetType]++ + } + + if service.Auth.PasswordAuth != nil && service.Auth.PasswordAuth.Enabled { + servicesAuthPassword++ + } + if service.Auth.PinAuth != nil && service.Auth.PinAuth.Enabled { + servicesAuthPin++ + } + if service.Auth.BearerAuth != nil && service.Auth.BearerAuth.Enabled { + servicesAuthOIDC++ + } + } } minActivePeerVersion, maxActivePeerVersion := getMinMaxVersion(peerActiveVersions) @@ -369,6 +419,27 @@ func (w *Worker) generateProperties(ctx context.Context) properties { metricsProperties["rosenpass_enabled"] = rosenpassEnabled metricsProperties["local_users_count"] = localUsers metricsProperties["idp_users_count"] = idpUsers + metricsProperties["embedded_idp_count"] = len(embeddedIdpTypes) + + metricsProperties["services"] = services + metricsProperties["services_enabled"] = servicesEnabled + metricsProperties["services_targets"] = servicesTargets + metricsProperties["services_status_active"] = servicesStatusActive + metricsProperties["services_status_pending"] = servicesStatusPending + metricsProperties["services_status_error"] = servicesStatusError + metricsProperties["services_auth_password"] = servicesAuthPassword + metricsProperties["services_auth_pin"] = servicesAuthPin + metricsProperties["services_auth_oidc"] = servicesAuthOIDC + metricsProperties["custom_domains"] = customDomains + metricsProperties["custom_domains_validated"] = customDomainsValidated + + for targetType, count := range servicesTargetType { + metricsProperties["services_target_type_"+string(targetType)] = count + } + + for idpType, count := range embeddedIdpTypes { + metricsProperties["embedded_idp_users_"+idpType] = count + } for protocol, count := range rulesProtocol { metricsProperties["rules_protocol_"+protocol] = count @@ -456,6 +527,20 @@ func createPostRequest(ctx context.Context, endpoint string, payloadStr string) return req, cancel, nil } +// extractIdpType extracts the IdP type from a Dex connector ID. +// Connector IDs are formatted as "-" (e.g., "okta-abc123", "zitadel-xyz"). +// Returns the type prefix, or "oidc" if no known prefix is found. +func extractIdpType(connectorID string) string { + if connectorID == "local" { + return "local" + } + idx := strings.LastIndex(connectorID, "-") + if idx <= 0 { + return "oidc" + } + return strings.ToLower(connectorID[:idx]) +} + func getMinMaxVersion(inputList []string) (string, string) { versions := make([]*version.Version, 0) diff --git a/management/server/metrics/selfhosted_test.go b/management/server/metrics/selfhosted_test.go index d0ab45cd7..78f5c53be 100644 --- a/management/server/metrics/selfhosted_test.go +++ b/management/server/metrics/selfhosted_test.go @@ -6,6 +6,7 @@ import ( nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/idp/dex" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" networkTypes "github.com/netbirdio/netbird/management/server/networks/types" @@ -27,7 +28,8 @@ func (mockDatasource) GetAllConnectedPeers() map[string]struct{} { // GetAllAccounts returns a list of *server.Account for use in tests with predefined information func (mockDatasource) GetAllAccounts(_ context.Context) []*types.Account { localUserID := dex.EncodeDexUserID("10", "local") - idpUserID := dex.EncodeDexUserID("20", "zitadel") + idpUserID := dex.EncodeDexUserID("20", "zitadel-d5uv82dra0haedlf6kv0") + oidcUserID := dex.EncodeDexUserID("30", "d6jvvp69kmnc73c9pl40") return []*types.Account{ { Id: "1", @@ -115,6 +117,31 @@ func (mockDatasource) GetAllAccounts(_ context.Context) []*types.Account { }, }, }, + Services: []*rpservice.Service{ + { + ID: "svc1", + Enabled: true, + Targets: []*rpservice.Target{ + {TargetType: "peer"}, + {TargetType: "host"}, + }, + Auth: rpservice.AuthConfig{ + PasswordAuth: &rpservice.PasswordAuthConfig{Enabled: true}, + }, + Meta: rpservice.Meta{Status: string(rpservice.StatusActive)}, + }, + { + ID: "svc2", + Enabled: false, + Targets: []*rpservice.Target{ + {TargetType: "domain"}, + }, + Auth: rpservice.AuthConfig{ + BearerAuth: &rpservice.BearerAuthConfig{Enabled: true}, + }, + Meta: rpservice.Meta{Status: string(rpservice.StatusPending)}, + }, + }, }, { Id: "2", @@ -180,6 +207,13 @@ func (mockDatasource) GetAllAccounts(_ context.Context) []*types.Account { "1": {}, }, }, + oidcUserID: { + Id: oidcUserID, + IsServiceUser: false, + PATs: map[string]*types.PersonalAccessToken{ + "1": {}, + }, + }, }, Networks: []*networkTypes.Network{ { @@ -215,6 +249,11 @@ func (mockDatasource) GetStoreEngine() types.Engine { return types.FileStoreEngine } +// GetCustomDomainsCounts returns test custom domain counts. +func (mockDatasource) GetCustomDomainsCounts(_ context.Context) (int64, int64, error) { + return 3, 2, nil +} + // TestGenerateProperties tests and validate the properties generation by using the mockDatasource for the Worker.generateProperties func TestGenerateProperties(t *testing.T) { ds := mockDatasource{} @@ -247,14 +286,14 @@ func TestGenerateProperties(t *testing.T) { if properties["rules"] != 4 { t.Errorf("expected 4 rules, got %d", properties["rules"]) } - if properties["users"] != 2 { - t.Errorf("expected 1 users, got %d", properties["users"]) + if properties["users"] != 3 { + t.Errorf("expected 3 users, got %d", properties["users"]) } if properties["setup_keys_usage"] != 2 { t.Errorf("expected 1 setup_keys_usage, got %d", properties["setup_keys_usage"]) } - if properties["pats"] != 4 { - t.Errorf("expected 4 personal_access_tokens, got %d", properties["pats"]) + if properties["pats"] != 5 { + t.Errorf("expected 5 personal_access_tokens, got %d", properties["pats"]) } if properties["peers_ssh_enabled"] != 2 { t.Errorf("expected 2 peers_ssh_enabled, got %d", properties["peers_ssh_enabled"]) @@ -338,7 +377,90 @@ func TestGenerateProperties(t *testing.T) { if properties["local_users_count"] != 1 { t.Errorf("expected 1 local_users_count, got %d", properties["local_users_count"]) } - if properties["idp_users_count"] != 1 { - t.Errorf("expected 1 idp_users_count, got %d", properties["idp_users_count"]) + if properties["idp_users_count"] != 2 { + t.Errorf("expected 2 idp_users_count, got %d", properties["idp_users_count"]) + } + if properties["embedded_idp_users_local"] != 1 { + t.Errorf("expected 1 embedded_idp_users_local, got %v", properties["embedded_idp_users_local"]) + } + if properties["embedded_idp_users_zitadel"] != 1 { + t.Errorf("expected 1 embedded_idp_users_zitadel, got %v", properties["embedded_idp_users_zitadel"]) + } + if properties["embedded_idp_users_oidc"] != 1 { + t.Errorf("expected 1 embedded_idp_users_oidc, got %v", properties["embedded_idp_users_oidc"]) + } + if properties["embedded_idp_count"] != 3 { + t.Errorf("expected 3 embedded_idp_count, got %v", properties["embedded_idp_count"]) + } + + if properties["services"] != 2 { + t.Errorf("expected 2 services, got %v", properties["services"]) + } + if properties["services_enabled"] != 1 { + t.Errorf("expected 1 services_enabled, got %v", properties["services_enabled"]) + } + if properties["services_targets"] != 3 { + t.Errorf("expected 3 services_targets, got %v", properties["services_targets"]) + } + if properties["services_status_active"] != 1 { + t.Errorf("expected 1 services_status_active, got %v", properties["services_status_active"]) + } + if properties["services_status_pending"] != 1 { + t.Errorf("expected 1 services_status_pending, got %v", properties["services_status_pending"]) + } + if properties["services_status_error"] != 0 { + t.Errorf("expected 0 services_status_error, got %v", properties["services_status_error"]) + } + if properties["services_target_type_peer"] != 1 { + t.Errorf("expected 1 services_target_type_peer, got %v", properties["services_target_type_peer"]) + } + if properties["services_target_type_host"] != 1 { + t.Errorf("expected 1 services_target_type_host, got %v", properties["services_target_type_host"]) + } + if properties["services_target_type_domain"] != 1 { + t.Errorf("expected 1 services_target_type_domain, got %v", properties["services_target_type_domain"]) + } + if properties["services_auth_password"] != 1 { + t.Errorf("expected 1 services_auth_password, got %v", properties["services_auth_password"]) + } + if properties["services_auth_oidc"] != 1 { + t.Errorf("expected 1 services_auth_oidc, got %v", properties["services_auth_oidc"]) + } + if properties["services_auth_pin"] != 0 { + t.Errorf("expected 0 services_auth_pin, got %v", properties["services_auth_pin"]) + } + if properties["custom_domains"] != int64(3) { + t.Errorf("expected 3 custom_domains, got %v", properties["custom_domains"]) + } + if properties["custom_domains_validated"] != int64(2) { + t.Errorf("expected 2 custom_domains_validated, got %v", properties["custom_domains_validated"]) + } +} + +func TestExtractIdpType(t *testing.T) { + tests := []struct { + connectorID string + expected string + }{ + {"okta-abc123def", "okta"}, + {"zitadel-d5uv82dra0haedlf6kv0", "zitadel"}, + {"entra-xyz789", "entra"}, + {"google-abc123", "google"}, + {"pocketid-abc123", "pocketid"}, + {"microsoft-abc123", "microsoft"}, + {"authentik-abc123", "authentik"}, + {"keycloak-d5uv82dra0haedlf6kv0", "keycloak"}, + {"local", "local"}, + {"d6jvvp69kmnc73c9pl40", "oidc"}, + {"", "oidc"}, + } + + for _, tt := range tests { + t.Run(tt.connectorID, func(t *testing.T) { + result := extractIdpType(tt.connectorID) + if result != tt.expected { + t.Errorf("extractIdpType(%q) = %q, want %q", tt.connectorID, result, tt.expected) + } + }) } } diff --git a/management/server/mock_server/account_mock.go b/management/server/mock_server/account_mock.go index 026989898..afd2021ac 100644 --- a/management/server/mock_server/account_mock.go +++ b/management/server/mock_server/account_mock.go @@ -12,6 +12,7 @@ import ( "google.golang.org/grpc/status" nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/idp" @@ -37,8 +38,8 @@ type MockAccountManager struct { GetUserFromUserAuthFunc func(ctx context.Context, userAuth auth.UserAuth) (*types.User, error) ListUsersFunc func(ctx context.Context, accountID string) ([]*types.User, error) GetPeersFunc func(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) - MarkPeerConnectedFunc func(ctx context.Context, peerKey string, connected bool, realIP net.IP) error - SyncAndMarkPeerFunc func(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) + MarkPeerConnectedFunc func(ctx context.Context, peerKey string, connected bool, realIP net.IP, syncTime time.Time) error + SyncAndMarkPeerFunc func(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) DeletePeerFunc func(ctx context.Context, accountID, peerKey, userID string) error GetNetworkMapFunc func(ctx context.Context, peerKey string) (*types.NetworkMap, error) GetPeerNetworkFunc func(ctx context.Context, peerKey string) (*types.Network, error) @@ -147,6 +148,10 @@ type MockAccountManager struct { DeleteUserInviteFunc func(ctx context.Context, accountID, initiatorUserID, inviteID string) error } +func (am *MockAccountManager) SetServiceManager(serviceManager service.Manager) { + // Mock implementation - no-op +} + func (am *MockAccountManager) CreatePeerJob(ctx context.Context, accountID, peerID, userID string, job *types.Job) error { if am.CreatePeerJobFunc != nil { return am.CreatePeerJobFunc(ctx, accountID, peerID, userID, job) @@ -214,16 +219,15 @@ func (am *MockAccountManager) DeleteSetupKey(ctx context.Context, accountID, use return status.Errorf(codes.Unimplemented, "method DeleteSetupKey is not implemented") } -func (am *MockAccountManager) SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) { +func (am *MockAccountManager) SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) { if am.SyncAndMarkPeerFunc != nil { - return am.SyncAndMarkPeerFunc(ctx, accountID, peerPubKey, meta, realIP) + return am.SyncAndMarkPeerFunc(ctx, accountID, peerPubKey, meta, realIP, syncTime) } return nil, nil, nil, 0, status.Errorf(codes.Unimplemented, "method MarkPeerConnected is not implemented") } -func (am *MockAccountManager) OnPeerDisconnected(_ context.Context, accountID string, peerPubKey string) error { - // TODO implement me - panic("implement me") +func (am *MockAccountManager) OnPeerDisconnected(_ context.Context, accountID string, peerPubKey string, streamStartTime time.Time) error { + return nil } func (am *MockAccountManager) GetValidatedPeers(ctx context.Context, accountID string) (map[string]struct{}, map[string]string, error) { @@ -323,9 +327,9 @@ func (am *MockAccountManager) GetAccountIDByUserID(ctx context.Context, userAuth } // MarkPeerConnected mock implementation of MarkPeerConnected from server.AccountManager interface -func (am *MockAccountManager) MarkPeerConnected(ctx context.Context, peerKey string, connected bool, realIP net.IP, accountID string) error { +func (am *MockAccountManager) MarkPeerConnected(ctx context.Context, peerKey string, connected bool, realIP net.IP, accountID string, syncTime time.Time) error { if am.MarkPeerConnectedFunc != nil { - return am.MarkPeerConnectedFunc(ctx, peerKey, connected, realIP) + return am.MarkPeerConnectedFunc(ctx, peerKey, connected, realIP, syncTime) } return status.Errorf(codes.Unimplemented, "method MarkPeerConnected is not implemented") } @@ -403,7 +407,7 @@ func (am *MockAccountManager) AddPeer( // GetGroupByName mock implementation of GetGroupByName from server.AccountManager interface func (am *MockAccountManager) GetGroupByName(ctx context.Context, accountID, groupName string) (*types.Group, error) { - if am.GetGroupFunc != nil { + if am.GetGroupByNameFunc != nil { return am.GetGroupByNameFunc(ctx, accountID, groupName) } return nil, status.Errorf(codes.Unimplemented, "method GetGroupByName is not implemented") diff --git a/management/server/nameserver.go b/management/server/nameserver.go index a3eb4ae2e..3d8c78912 100644 --- a/management/server/nameserver.go +++ b/management/server/nameserver.go @@ -3,10 +3,10 @@ package server import ( "context" "errors" - "regexp" + "fmt" + "strings" "unicode/utf8" - "github.com/miekg/dns" "github.com/rs/xid" nbdns "github.com/netbirdio/netbird/dns" @@ -15,11 +15,10 @@ import ( "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/management/server/store" "github.com/netbirdio/netbird/management/server/types" + nbdomain "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/management/status" ) -const domainPattern = `^(?i)[a-z0-9]+([\-\.]{1}[a-z0-9]+)*[*.a-z]{1,}$` - var errInvalidDomainName = errors.New("invalid domain name") // GetNameServerGroup gets a nameserver group object from account and nameserver group IDs @@ -305,16 +304,18 @@ func validateGroups(list []string, groups map[string]*types.Group) error { return nil } -var domainMatcher = regexp.MustCompile(domainPattern) - -func validateDomain(domain string) error { - if !domainMatcher.MatchString(domain) { - return errors.New("domain should consists of only letters, numbers, and hyphens with no leading, trailing hyphens, or spaces") +// validateDomain validates a nameserver match domain. +// Converts unicode to punycode. Wildcards are not allowed for nameservers. +func validateDomain(d string) error { + if strings.HasPrefix(d, "*.") { + return errors.New("wildcards not allowed") } - _, valid := dns.IsDomainName(domain) - if !valid { - return errInvalidDomainName + // Nameservers allow trailing dot (FQDN format) + toValidate := strings.TrimSuffix(d, ".") + + if _, err := nbdomain.ValidateDomains([]string{toValidate}); err != nil { + return fmt.Errorf("%w: %w", errInvalidDomainName, err) } return nil diff --git a/management/server/nameserver_test.go b/management/server/nameserver_test.go index 0d781e0d4..90b4b9687 100644 --- a/management/server/nameserver_test.go +++ b/management/server/nameserver_test.go @@ -901,82 +901,53 @@ func initTestNSAccount(t *testing.T, am *DefaultAccountManager) (*types.Account, return account, nil } +// TestValidateDomain tests nameserver-specific domain validation. +// Core domain validation is tested in shared/management/domain/validate_test.go. +// This test only covers nameserver-specific behavior: wildcard rejection and unicode support. func TestValidateDomain(t *testing.T) { testCases := []struct { name string domain string errFunc require.ErrorAssertionFunc }{ + // Nameserver-specific: wildcards not allowed { - name: "Valid domain name with multiple labels", - domain: "123.example.com", + name: "Wildcard prefix rejected", + domain: "*.example.com", + errFunc: require.Error, + }, + { + name: "Wildcard in middle rejected", + domain: "a.*.example.com", + errFunc: require.Error, + }, + // Nameserver-specific: unicode converted to punycode + { + name: "Unicode domain converted to punycode", + domain: "münchen.de", errFunc: require.NoError, }, { - name: "Valid domain name with hyphen", - domain: "test-example.com", + name: "Unicode domain all labels", + domain: "中国.中国", + errFunc: require.NoError, + }, + // Basic validation still works (delegates to shared validation) + { + name: "Valid multi-label domain", + domain: "example.com", errFunc: require.NoError, }, { - name: "Valid domain name with only one label", - domain: "example", + name: "Valid single label", + domain: "internal", errFunc: require.NoError, }, { - name: "Valid domain name with trailing dot", - domain: "example.", - errFunc: require.NoError, - }, - { - name: "Invalid wildcard domain name", - domain: "*.example", - errFunc: require.Error, - }, - { - name: "Invalid domain name with leading dot", - domain: ".com", - errFunc: require.Error, - }, - { - name: "Invalid domain name with dot only", - domain: ".", - errFunc: require.Error, - }, - { - name: "Invalid domain name with double hyphen", - domain: "test--example.com", - errFunc: require.Error, - }, - { - name: "Invalid domain name with a label exceeding 63 characters", - domain: "dnsdnsdnsdnsdnsdnsdnsdnsdnsdnsdnsdnsdnsdnsdnsdnsdnsdnsdnsdnsdnsdns.com", - errFunc: require.Error, - }, - { - name: "Invalid domain name starting with a hyphen", + name: "Invalid leading hyphen", domain: "-example.com", errFunc: require.Error, }, - { - name: "Invalid domain name ending with a hyphen", - domain: "example.com-", - errFunc: require.Error, - }, - { - name: "Invalid domain with unicode", - domain: "example?,.com", - errFunc: require.Error, - }, - { - name: "Invalid domain with space before top-level domain", - domain: "space .example.com", - errFunc: require.Error, - }, - { - name: "Invalid domain with trailing space", - domain: "example.com ", - errFunc: require.Error, - }, } for _, testCase := range testCases { diff --git a/management/server/networks/manager_test.go b/management/server/networks/manager_test.go index bf196fcb3..6fb19d157 100644 --- a/management/server/networks/manager_test.go +++ b/management/server/networks/manager_test.go @@ -29,7 +29,7 @@ func Test_GetAllNetworksReturnsNetworks(t *testing.T) { permissionsManager := permissions.NewManager(s) groupsManager := groups.NewManagerMock() routerManager := routers.NewManagerMock() - resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am) + resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am, nil) manager := NewManager(s, permissionsManager, resourcesManager, routerManager, &am) networks, err := manager.GetAllNetworks(ctx, accountID, userID) @@ -52,7 +52,7 @@ func Test_GetAllNetworksReturnsPermissionDenied(t *testing.T) { permissionsManager := permissions.NewManager(s) groupsManager := groups.NewManagerMock() routerManager := routers.NewManagerMock() - resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am) + resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am, nil) manager := NewManager(s, permissionsManager, resourcesManager, routerManager, &am) networks, err := manager.GetAllNetworks(ctx, accountID, userID) @@ -75,7 +75,7 @@ func Test_GetNetworkReturnsNetwork(t *testing.T) { permissionsManager := permissions.NewManager(s) groupsManager := groups.NewManagerMock() routerManager := routers.NewManagerMock() - resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am) + resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am, nil) manager := NewManager(s, permissionsManager, resourcesManager, routerManager, &am) networks, err := manager.GetNetwork(ctx, accountID, userID, networkID) @@ -98,7 +98,7 @@ func Test_GetNetworkReturnsPermissionDenied(t *testing.T) { permissionsManager := permissions.NewManager(s) groupsManager := groups.NewManagerMock() routerManager := routers.NewManagerMock() - resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am) + resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am, nil) manager := NewManager(s, permissionsManager, resourcesManager, routerManager, &am) network, err := manager.GetNetwork(ctx, accountID, userID, networkID) @@ -123,7 +123,7 @@ func Test_CreateNetworkSuccessfully(t *testing.T) { permissionsManager := permissions.NewManager(s) groupsManager := groups.NewManagerMock() routerManager := routers.NewManagerMock() - resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am) + resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am, nil) manager := NewManager(s, permissionsManager, resourcesManager, routerManager, &am) createdNetwork, err := manager.CreateNetwork(ctx, userID, network) @@ -148,7 +148,7 @@ func Test_CreateNetworkFailsWithPermissionDenied(t *testing.T) { permissionsManager := permissions.NewManager(s) groupsManager := groups.NewManagerMock() routerManager := routers.NewManagerMock() - resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am) + resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am, nil) manager := NewManager(s, permissionsManager, resourcesManager, routerManager, &am) createdNetwork, err := manager.CreateNetwork(ctx, userID, network) @@ -171,7 +171,7 @@ func Test_DeleteNetworkSuccessfully(t *testing.T) { permissionsManager := permissions.NewManager(s) groupsManager := groups.NewManagerMock() routerManager := routers.NewManagerMock() - resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am) + resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am, nil) manager := NewManager(s, permissionsManager, resourcesManager, routerManager, &am) err = manager.DeleteNetwork(ctx, accountID, userID, networkID) @@ -193,7 +193,7 @@ func Test_DeleteNetworkFailsWithPermissionDenied(t *testing.T) { permissionsManager := permissions.NewManager(s) groupsManager := groups.NewManagerMock() routerManager := routers.NewManagerMock() - resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am) + resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am, nil) manager := NewManager(s, permissionsManager, resourcesManager, routerManager, &am) err = manager.DeleteNetwork(ctx, accountID, userID, networkID) @@ -218,7 +218,7 @@ func Test_UpdateNetworkSuccessfully(t *testing.T) { permissionsManager := permissions.NewManager(s) groupsManager := groups.NewManagerMock() routerManager := routers.NewManagerMock() - resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am) + resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am, nil) manager := NewManager(s, permissionsManager, resourcesManager, routerManager, &am) updatedNetwork, err := manager.UpdateNetwork(ctx, userID, network) @@ -245,7 +245,7 @@ func Test_UpdateNetworkFailsWithPermissionDenied(t *testing.T) { permissionsManager := permissions.NewManager(s) groupsManager := groups.NewManagerMock() routerManager := routers.NewManagerMock() - resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am) + resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am, nil) manager := NewManager(s, permissionsManager, resourcesManager, routerManager, &am) updatedNetwork, err := manager.UpdateNetwork(ctx, userID, network) diff --git a/management/server/networks/resources/manager.go b/management/server/networks/resources/manager.go index 66484d120..86f9b6579 100644 --- a/management/server/networks/resources/manager.go +++ b/management/server/networks/resources/manager.go @@ -5,6 +5,9 @@ import ( "errors" "fmt" + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/groups" @@ -34,17 +37,19 @@ type managerImpl struct { permissionsManager permissions.Manager groupsManager groups.Manager accountManager account.Manager + serviceManager service.Manager } type mockManager struct { } -func NewManager(store store.Store, permissionsManager permissions.Manager, groupsManager groups.Manager, accountManager account.Manager) Manager { +func NewManager(store store.Store, permissionsManager permissions.Manager, groupsManager groups.Manager, accountManager account.Manager, reverseproxyManager service.Manager) Manager { return &managerImpl{ store: store, permissionsManager: permissionsManager, groupsManager: groupsManager, accountManager: accountManager, + serviceManager: reverseproxyManager, } } @@ -257,6 +262,14 @@ func (m *managerImpl) UpdateResource(ctx context.Context, userID string, resourc event() } + // TODO: optimize to only reload reverse proxies that are affected by the resource update instead of all of them + go func() { + err := m.serviceManager.ReloadAllServicesForAccount(ctx, resource.AccountID) + if err != nil { + log.WithContext(ctx).Warnf("failed to reload all proxies for account: %v", err) + } + }() + go m.accountManager.UpdateAccountPeers(ctx, resource.AccountID) return resource, nil @@ -309,6 +322,14 @@ func (m *managerImpl) DeleteResource(ctx context.Context, accountID, userID, net return status.NewPermissionDeniedError() } + serviceID, err := m.serviceManager.GetServiceIDByTargetID(ctx, accountID, resourceID) + if err != nil { + return fmt.Errorf("failed to check if resource is used by service: %w", err) + } + if serviceID != "" { + return status.NewResourceInUseError(resourceID, serviceID) + } + var events []func() err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { events, err = m.DeleteResourceInTransaction(ctx, transaction, accountID, userID, networkID, resourceID) diff --git a/management/server/networks/resources/manager_test.go b/management/server/networks/resources/manager_test.go index e2dea2c6b..c6d8e7bcc 100644 --- a/management/server/networks/resources/manager_test.go +++ b/management/server/networks/resources/manager_test.go @@ -4,8 +4,10 @@ import ( "context" "testing" + "github.com/golang/mock/gomock" "github.com/stretchr/testify/require" + reverseproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" "github.com/netbirdio/netbird/management/server/groups" "github.com/netbirdio/netbird/management/server/mock_server" "github.com/netbirdio/netbird/management/server/networks/resources/types" @@ -28,7 +30,9 @@ func Test_GetAllResourcesInNetworkReturnsResources(t *testing.T) { permissionsManager := permissions.NewManager(store) am := mock_server.MockAccountManager{} groupsManager := groups.NewManagerMock() - manager := NewManager(store, permissionsManager, groupsManager, &am) + ctrl := gomock.NewController(t) + serviceManager := reverseproxy.NewMockManager(ctrl) + manager := NewManager(store, permissionsManager, groupsManager, &am, serviceManager) resources, err := manager.GetAllResourcesInNetwork(ctx, accountID, userID, networkID) require.NoError(t, err) @@ -49,7 +53,9 @@ func Test_GetAllResourcesInNetworkReturnsPermissionDenied(t *testing.T) { permissionsManager := permissions.NewManager(store) am := mock_server.MockAccountManager{} groupsManager := groups.NewManagerMock() - manager := NewManager(store, permissionsManager, groupsManager, &am) + ctrl := gomock.NewController(t) + serviceManager := reverseproxy.NewMockManager(ctrl) + manager := NewManager(store, permissionsManager, groupsManager, &am, serviceManager) resources, err := manager.GetAllResourcesInNetwork(ctx, accountID, userID, networkID) require.Error(t, err) @@ -69,7 +75,9 @@ func Test_GetAllResourcesInAccountReturnsResources(t *testing.T) { permissionsManager := permissions.NewManager(store) am := mock_server.MockAccountManager{} groupsManager := groups.NewManagerMock() - manager := NewManager(store, permissionsManager, groupsManager, &am) + ctrl := gomock.NewController(t) + serviceManager := reverseproxy.NewMockManager(ctrl) + manager := NewManager(store, permissionsManager, groupsManager, &am, serviceManager) resources, err := manager.GetAllResourcesInAccount(ctx, accountID, userID) require.NoError(t, err) @@ -89,7 +97,9 @@ func Test_GetAllResourcesInAccountReturnsPermissionDenied(t *testing.T) { permissionsManager := permissions.NewManager(store) am := mock_server.MockAccountManager{} groupsManager := groups.NewManagerMock() - manager := NewManager(store, permissionsManager, groupsManager, &am) + ctrl := gomock.NewController(t) + serviceManager := reverseproxy.NewMockManager(ctrl) + manager := NewManager(store, permissionsManager, groupsManager, &am, serviceManager) resources, err := manager.GetAllResourcesInAccount(ctx, accountID, userID) require.Error(t, err) @@ -112,7 +122,9 @@ func Test_GetResourceInNetworkReturnsResources(t *testing.T) { permissionsManager := permissions.NewManager(store) am := mock_server.MockAccountManager{} groupsManager := groups.NewManagerMock() - manager := NewManager(store, permissionsManager, groupsManager, &am) + ctrl := gomock.NewController(t) + serviceManager := reverseproxy.NewMockManager(ctrl) + manager := NewManager(store, permissionsManager, groupsManager, &am, serviceManager) resource, err := manager.GetResource(ctx, accountID, userID, networkID, resourceID) require.NoError(t, err) @@ -134,7 +146,9 @@ func Test_GetResourceInNetworkReturnsPermissionDenied(t *testing.T) { permissionsManager := permissions.NewManager(store) am := mock_server.MockAccountManager{} groupsManager := groups.NewManagerMock() - manager := NewManager(store, permissionsManager, groupsManager, &am) + ctrl := gomock.NewController(t) + serviceManager := reverseproxy.NewMockManager(ctrl) + manager := NewManager(store, permissionsManager, groupsManager, &am, serviceManager) resources, err := manager.GetResource(ctx, accountID, userID, networkID, resourceID) require.Error(t, err) @@ -161,7 +175,10 @@ func Test_CreateResourceSuccessfully(t *testing.T) { permissionsManager := permissions.NewManager(store) am := mock_server.MockAccountManager{} groupsManager := groups.NewManagerMock() - manager := NewManager(store, permissionsManager, groupsManager, &am) + ctrl := gomock.NewController(t) + serviceManager := reverseproxy.NewMockManager(ctrl) + serviceManager.EXPECT().ReloadAllServicesForAccount(gomock.Any(), resource.AccountID).Return(nil).AnyTimes() + manager := NewManager(store, permissionsManager, groupsManager, &am, serviceManager) createdResource, err := manager.CreateResource(ctx, userID, resource) require.NoError(t, err) @@ -187,7 +204,9 @@ func Test_CreateResourceFailsWithPermissionDenied(t *testing.T) { permissionsManager := permissions.NewManager(store) am := mock_server.MockAccountManager{} groupsManager := groups.NewManagerMock() - manager := NewManager(store, permissionsManager, groupsManager, &am) + ctrl := gomock.NewController(t) + serviceManager := reverseproxy.NewMockManager(ctrl) + manager := NewManager(store, permissionsManager, groupsManager, &am, serviceManager) createdResource, err := manager.CreateResource(ctx, userID, resource) require.Error(t, err) @@ -203,7 +222,7 @@ func Test_CreateResourceFailsWithInvalidAddress(t *testing.T) { NetworkID: "testNetworkId", Name: "testResourceId", Description: "description", - Address: "invalid-address", + Address: "-invalid", } store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "../../testdata/networks.sql", t.TempDir()) @@ -214,7 +233,9 @@ func Test_CreateResourceFailsWithInvalidAddress(t *testing.T) { permissionsManager := permissions.NewManager(store) am := mock_server.MockAccountManager{} groupsManager := groups.NewManagerMock() - manager := NewManager(store, permissionsManager, groupsManager, &am) + ctrl := gomock.NewController(t) + serviceManager := reverseproxy.NewMockManager(ctrl) + manager := NewManager(store, permissionsManager, groupsManager, &am, serviceManager) createdResource, err := manager.CreateResource(ctx, userID, resource) require.Error(t, err) @@ -227,9 +248,9 @@ func Test_CreateResourceFailsWithUsedName(t *testing.T) { resource := &types.NetworkResource{ AccountID: "testAccountId", NetworkID: "testNetworkId", - Name: "testResourceId", + Name: "used-name", Description: "description", - Address: "invalid-address", + Address: "example.com", } store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "../../testdata/networks.sql", t.TempDir()) @@ -240,7 +261,9 @@ func Test_CreateResourceFailsWithUsedName(t *testing.T) { permissionsManager := permissions.NewManager(store) am := mock_server.MockAccountManager{} groupsManager := groups.NewManagerMock() - manager := NewManager(store, permissionsManager, groupsManager, &am) + ctrl := gomock.NewController(t) + serviceManager := reverseproxy.NewMockManager(ctrl) + manager := NewManager(store, permissionsManager, groupsManager, &am, serviceManager) createdResource, err := manager.CreateResource(ctx, userID, resource) require.Error(t, err) @@ -270,7 +293,10 @@ func Test_UpdateResourceSuccessfully(t *testing.T) { permissionsManager := permissions.NewManager(store) am := mock_server.MockAccountManager{} groupsManager := groups.NewManagerMock() - manager := NewManager(store, permissionsManager, groupsManager, &am) + ctrl := gomock.NewController(t) + serviceManager := reverseproxy.NewMockManager(ctrl) + serviceManager.EXPECT().ReloadAllServicesForAccount(gomock.Any(), accountID).Return(nil).AnyTimes() + manager := NewManager(store, permissionsManager, groupsManager, &am, serviceManager) updatedResource, err := manager.UpdateResource(ctx, userID, resource) require.NoError(t, err) @@ -302,7 +328,9 @@ func Test_UpdateResourceFailsWithResourceNotFound(t *testing.T) { permissionsManager := permissions.NewManager(store) am := mock_server.MockAccountManager{} groupsManager := groups.NewManagerMock() - manager := NewManager(store, permissionsManager, groupsManager, &am) + ctrl := gomock.NewController(t) + serviceManager := reverseproxy.NewMockManager(ctrl) + manager := NewManager(store, permissionsManager, groupsManager, &am, serviceManager) updatedResource, err := manager.UpdateResource(ctx, userID, resource) require.Error(t, err) @@ -332,7 +360,9 @@ func Test_UpdateResourceFailsWithNameInUse(t *testing.T) { permissionsManager := permissions.NewManager(store) am := mock_server.MockAccountManager{} groupsManager := groups.NewManagerMock() - manager := NewManager(store, permissionsManager, groupsManager, &am) + ctrl := gomock.NewController(t) + serviceManager := reverseproxy.NewMockManager(ctrl) + manager := NewManager(store, permissionsManager, groupsManager, &am, serviceManager) updatedResource, err := manager.UpdateResource(ctx, userID, resource) require.Error(t, err) @@ -361,7 +391,9 @@ func Test_UpdateResourceFailsWithPermissionDenied(t *testing.T) { permissionsManager := permissions.NewManager(store) am := mock_server.MockAccountManager{} groupsManager := groups.NewManagerMock() - manager := NewManager(store, permissionsManager, groupsManager, &am) + ctrl := gomock.NewController(t) + serviceManager := reverseproxy.NewMockManager(ctrl) + manager := NewManager(store, permissionsManager, groupsManager, &am, serviceManager) updatedResource, err := manager.UpdateResource(ctx, userID, resource) require.Error(t, err) @@ -383,7 +415,10 @@ func Test_DeleteResourceSuccessfully(t *testing.T) { permissionsManager := permissions.NewManager(store) am := mock_server.MockAccountManager{} groupsManager := groups.NewManagerMock() - manager := NewManager(store, permissionsManager, groupsManager, &am) + ctrl := gomock.NewController(t) + serviceManager := reverseproxy.NewMockManager(ctrl) + serviceManager.EXPECT().GetServiceIDByTargetID(gomock.Any(), accountID, resourceID).Return("", nil).AnyTimes() + manager := NewManager(store, permissionsManager, groupsManager, &am, serviceManager) err = manager.DeleteResource(ctx, accountID, userID, networkID, resourceID) require.NoError(t, err) @@ -404,7 +439,9 @@ func Test_DeleteResourceFailsWithPermissionDenied(t *testing.T) { permissionsManager := permissions.NewManager(store) am := mock_server.MockAccountManager{} groupsManager := groups.NewManagerMock() - manager := NewManager(store, permissionsManager, groupsManager, &am) + ctrl := gomock.NewController(t) + serviceManager := reverseproxy.NewMockManager(ctrl) + manager := NewManager(store, permissionsManager, groupsManager, &am, serviceManager) err = manager.DeleteResource(ctx, accountID, userID, networkID, resourceID) require.Error(t, err) diff --git a/management/server/networks/resources/types/resource.go b/management/server/networks/resources/types/resource.go index 6b8cf9412..1fa908393 100644 --- a/management/server/networks/resources/types/resource.go +++ b/management/server/networks/resources/types/resource.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "net/netip" - "regexp" "github.com/rs/xid" @@ -166,8 +165,7 @@ func GetResourceType(address string) (NetworkResourceType, string, netip.Prefix, return Host, "", netip.PrefixFrom(ip, ip.BitLen()), nil } - domainRegex := regexp.MustCompile(`^(\*\.)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$`) - if domainRegex.MatchString(address) { + if _, err := nbDomain.ValidateDomains([]string{address}); err == nil { return Domain, address, netip.Prefix{}, nil } diff --git a/management/server/networks/resources/types/resource_test.go b/management/server/networks/resources/types/resource_test.go index 02e802300..a842b0a28 100644 --- a/management/server/networks/resources/types/resource_test.go +++ b/management/server/networks/resources/types/resource_test.go @@ -23,10 +23,12 @@ func TestGetResourceType(t *testing.T) { {"example.com", Domain, false, "example.com", netip.Prefix{}}, {"*.example.com", Domain, false, "*.example.com", netip.Prefix{}}, {"sub.example.com", Domain, false, "sub.example.com", netip.Prefix{}}, + {"example.x", Domain, false, "example.x", netip.Prefix{}}, + {"internal", Domain, false, "internal", netip.Prefix{}}, // Invalid inputs - {"invalid", "", true, "", netip.Prefix{}}, {"1.1.1.1/abc", "", true, "", netip.Prefix{}}, - {"1234", "", true, "", netip.Prefix{}}, + {"-invalid.com", "", true, "", netip.Prefix{}}, + {"", "", true, "", netip.Prefix{}}, } for _, tt := range tests { diff --git a/management/server/peer.go b/management/server/peer.go index 80c74e209..78ecbfcae 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -103,11 +103,13 @@ func (am *DefaultAccountManager) getUserAccessiblePeers(ctx context.Context, acc } // MarkPeerConnected marks peer as connected (true) or disconnected (false) -func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubKey string, connected bool, realIP net.IP, accountID string) error { +// syncTime is used as the LastSeen timestamp and for stale request detection +func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubKey string, connected bool, realIP net.IP, accountID string, syncTime time.Time) error { var peer *nbpeer.Peer var settings *types.Settings var expired bool var err error + var skipped bool err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { peer, err = transaction.GetPeerByPeerPubKey(ctx, store.LockingStrengthUpdate, peerPubKey) @@ -115,9 +117,19 @@ func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubK return err } - expired, err = updatePeerStatusAndLocation(ctx, am.geo, transaction, peer, connected, realIP, accountID) + if connected && !syncTime.After(peer.Status.LastSeen) { + log.WithContext(ctx).Tracef("peer %s has newer activity (lastSeen=%s >= syncTime=%s), skipping connect", + peer.ID, peer.Status.LastSeen.Format(time.RFC3339), syncTime.Format(time.RFC3339)) + skipped = true + return nil + } + + expired, err = updatePeerStatusAndLocation(ctx, am.geo, transaction, peer, connected, realIP, accountID, syncTime) return err }) + if skipped { + return nil + } if err != nil { return err } @@ -147,10 +159,10 @@ func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubK return nil } -func updatePeerStatusAndLocation(ctx context.Context, geo geolocation.Geolocation, transaction store.Store, peer *nbpeer.Peer, connected bool, realIP net.IP, accountID string) (bool, error) { +func updatePeerStatusAndLocation(ctx context.Context, geo geolocation.Geolocation, transaction store.Store, peer *nbpeer.Peer, connected bool, realIP net.IP, accountID string, syncTime time.Time) (bool, error) { oldStatus := peer.Status.Copy() newStatus := oldStatus - newStatus.LastSeen = time.Now().UTC() + newStatus.LastSeen = syncTime newStatus.Connected = connected // whenever peer got connected that means that it logged in successfully if newStatus.Connected { @@ -209,6 +221,10 @@ func (am *DefaultAccountManager) UpdatePeer(ctx context.Context, accountID, user return err } + if peer.ProxyMeta.Embedded { + return fmt.Errorf("not allowed to update peer") + } + settings, err = transaction.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) if err != nil { return err @@ -477,6 +493,14 @@ func (am *DefaultAccountManager) DeletePeer(ctx context.Context, accountID, peer var settings *types.Settings var eventsToStore []func() + serviceID, err := am.serviceManager.GetServiceIDByTargetID(ctx, accountID, peerID) + if err != nil { + return fmt.Errorf("failed to check if resource is used by service: %w", err) + } + if serviceID != "" { + return status.NewPeerInUseError(peerID, serviceID) + } + err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { peer, err = transaction.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID) if err != nil { @@ -537,6 +561,99 @@ func (am *DefaultAccountManager) GetPeerNetwork(ctx context.Context, peerID stri return account.Network.Copy(), err } +type peerAddAuthConfig struct { + AccountID string + SetupKeyID string + SetupKeyName string + GroupsToAdd []string + AllowExtraDNSLabels bool + Ephemeral bool +} + +func (am *DefaultAccountManager) processPeerAddAuth(ctx context.Context, accountID, userID, encodedHashedKey string, peer *nbpeer.Peer, temporary, addedByUser, addedBySetupKey bool, opEvent *activity.Event) (*peerAddAuthConfig, error) { + config := &peerAddAuthConfig{ + AccountID: accountID, + Ephemeral: peer.Ephemeral, + } + + switch { + case addedByUser: + if err := am.handleUserAddedPeer(ctx, accountID, userID, temporary, opEvent, config); err != nil { + return nil, err + } + case addedBySetupKey: + if err := am.handleSetupKeyAddedPeer(ctx, encodedHashedKey, peer, opEvent, config); err != nil { + return nil, err + } + default: + if peer.ProxyMeta.Embedded { + log.WithContext(ctx).Debugf("adding peer for proxy embedded, accountID: %s", accountID) + } else { + log.WithContext(ctx).Warnf("adding peer without setup key or userID, accountID: %s", accountID) + } + } + + opEvent.AccountID = config.AccountID + if temporary { + config.Ephemeral = true + } + + return config, nil +} + +func (am *DefaultAccountManager) handleUserAddedPeer(ctx context.Context, accountID, userID string, temporary bool, opEvent *activity.Event, config *peerAddAuthConfig) error { + user, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) + if err != nil { + return status.Errorf(status.NotFound, "failed adding new peer: user not found") + } + if user.PendingApproval { + return status.Errorf(status.PermissionDenied, "user pending approval cannot add peers") + } + + if temporary { + allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Create) + if err != nil { + return status.NewPermissionValidationError(err) + } + if !allowed { + return status.NewPermissionDeniedError() + } + } else { + config.AccountID = user.AccountID + config.GroupsToAdd = user.AutoGroups + } + + opEvent.InitiatorID = userID + opEvent.Activity = activity.PeerAddedByUser + return nil +} + +func (am *DefaultAccountManager) handleSetupKeyAddedPeer(ctx context.Context, encodedHashedKey string, peer *nbpeer.Peer, opEvent *activity.Event, config *peerAddAuthConfig) error { + sk, err := am.Store.GetSetupKeyBySecret(ctx, store.LockingStrengthNone, encodedHashedKey) + if err != nil { + return status.Errorf(status.NotFound, "couldn't add peer: setup key is invalid") + } + + if !sk.IsValid() { + return status.Errorf(status.NotFound, "couldn't add peer: setup key is invalid") + } + + if !sk.AllowExtraDNSLabels && len(peer.ExtraDNSLabels) > 0 { + return status.Errorf(status.PreconditionFailed, "couldn't add peer: setup key doesn't allow extra DNS labels") + } + + opEvent.InitiatorID = sk.Id + opEvent.Activity = activity.PeerAddedWithSetupKey + config.GroupsToAdd = sk.AutoGroups + config.Ephemeral = sk.Ephemeral + config.SetupKeyID = sk.Id + config.SetupKeyName = sk.Name + config.AllowExtraDNSLabels = sk.AllowExtraDNSLabels + config.AccountID = sk.AccountID + + return nil +} + // AddPeer adds a new peer to the Store. // Each Account has a list of pre-authorized SetupKey and if no Account has a given key err with a code status.PermissionDenied // will be returned, meaning the setup key is invalid or not found. @@ -545,7 +662,7 @@ func (am *DefaultAccountManager) GetPeerNetwork(ctx context.Context, peerID stri // Each new Peer will be assigned a new next net.IP from the Account.Network and Account.Network.LastIP will be updated (IP's are not reused). // The peer property is just a placeholder for the Peer properties to pass further func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) { - if setupKey == "" && userID == "" { + if setupKey == "" && userID == "" && !peer.ProxyMeta.Embedded { // no auth method provided => reject access return nil, nil, nil, status.Errorf(status.Unauthenticated, "no peer auth method provided, please use a setup key or interactive SSO login") } @@ -554,6 +671,7 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe hashedKey := sha256.Sum256([]byte(upperKey)) encodedHashedKey := b64.StdEncoding.EncodeToString(hashedKey[:]) addedByUser := len(userID) > 0 + addedBySetupKey := len(setupKey) > 0 // This is a handling for the case when the same machine (with the same WireGuard pub key) tries to register twice. // Such case is possible when AddPeer function takes long time to finish after AcquireWriteLockByUID (e.g., database is slow) @@ -571,63 +689,12 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe var newPeer *nbpeer.Peer - var setupKeyID string - var setupKeyName string - var ephemeral bool - var groupsToAdd []string - var allowExtraDNSLabels bool - if addedByUser { - user, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) - if err != nil { - return nil, nil, nil, status.Errorf(status.NotFound, "failed adding new peer: user not found") - } - if user.PendingApproval { - return nil, nil, nil, status.Errorf(status.PermissionDenied, "user pending approval cannot add peers") - } - if temporary { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Create) - if err != nil { - return nil, nil, nil, status.NewPermissionValidationError(err) - } - - if !allowed { - return nil, nil, nil, status.NewPermissionDeniedError() - } - } else { - accountID = user.AccountID - groupsToAdd = user.AutoGroups - } - opEvent.InitiatorID = userID - opEvent.Activity = activity.PeerAddedByUser - } else { - // Validate the setup key - sk, err := am.Store.GetSetupKeyBySecret(ctx, store.LockingStrengthNone, encodedHashedKey) - if err != nil { - return nil, nil, nil, status.Errorf(status.NotFound, "couldn't add peer: setup key is invalid") - } - - // we will check key twice for early return - if !sk.IsValid() { - return nil, nil, nil, status.Errorf(status.NotFound, "couldn't add peer: setup key is invalid") - } - - opEvent.InitiatorID = sk.Id - opEvent.Activity = activity.PeerAddedWithSetupKey - groupsToAdd = sk.AutoGroups - ephemeral = sk.Ephemeral - setupKeyID = sk.Id - setupKeyName = sk.Name - allowExtraDNSLabels = sk.AllowExtraDNSLabels - accountID = sk.AccountID - if !sk.AllowExtraDNSLabels && len(peer.ExtraDNSLabels) > 0 { - return nil, nil, nil, status.Errorf(status.PreconditionFailed, "couldn't add peer: setup key doesn't allow extra DNS labels") - } - } - opEvent.AccountID = accountID - - if temporary { - ephemeral = true + peerAddConfig, err := am.processPeerAddAuth(ctx, accountID, userID, encodedHashedKey, peer, temporary, addedByUser, addedBySetupKey, opEvent) + if err != nil { + return nil, nil, nil, err } + accountID = peerAddConfig.AccountID + ephemeral := peerAddConfig.Ephemeral if (strings.ToLower(peer.Meta.Hostname) == "iphone" || strings.ToLower(peer.Meta.Hostname) == "ipad") && userID != "" { if am.idpManager != nil { @@ -657,10 +724,11 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe CreatedAt: registrationTime, LoginExpirationEnabled: addedByUser && !temporary, Ephemeral: ephemeral, + ProxyMeta: peer.ProxyMeta, Location: peer.Location, InactivityExpirationEnabled: addedByUser && !temporary, ExtraDNSLabels: peer.ExtraDNSLabels, - AllowExtraDNSLabels: allowExtraDNSLabels, + AllowExtraDNSLabels: peerAddConfig.AllowExtraDNSLabels, } settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) if err != nil { @@ -678,7 +746,7 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe } } - newPeer = am.integratedPeerValidator.PreparePeer(ctx, accountID, newPeer, groupsToAdd, settings.Extra, temporary) + newPeer = am.integratedPeerValidator.PreparePeer(ctx, accountID, newPeer, peerAddConfig.GroupsToAdd, settings.Extra, temporary) network, err := am.Store.GetAccountNetwork(ctx, store.LockingStrengthNone, accountID) if err != nil { @@ -714,8 +782,8 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe return err } - if len(groupsToAdd) > 0 { - for _, g := range groupsToAdd { + if len(peerAddConfig.GroupsToAdd) > 0 { + for _, g := range peerAddConfig.GroupsToAdd { err = transaction.AddPeerToGroup(ctx, newPeer.AccountID, newPeer.ID, g) if err != nil { return err @@ -723,22 +791,20 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe } } - err = transaction.AddPeerToAllGroup(ctx, accountID, newPeer.ID) - if err != nil { - return fmt.Errorf("failed adding peer to All group: %w", err) + if !peer.ProxyMeta.Embedded { + err = transaction.AddPeerToAllGroup(ctx, accountID, newPeer.ID) + if err != nil { + return fmt.Errorf("failed adding peer to All group: %w", err) + } } - if temporary { - // we should track ephemeral peers to be able to clean them if the peer don't sync and be marked as connected - am.networkMapController.TrackEphemeralPeer(ctx, newPeer) - } - - if addedByUser { + switch { + case addedByUser: err := transaction.SaveUserLastLogin(ctx, accountID, userID, newPeer.GetLastLogin()) if err != nil { log.WithContext(ctx).Debugf("failed to update user last login: %v", err) } - } else { + case addedBySetupKey: sk, err := transaction.GetSetupKeyBySecret(ctx, store.LockingStrengthUpdate, encodedHashedKey) if err != nil { return fmt.Errorf("failed to get setup key: %w", err) @@ -749,7 +815,7 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe return status.Errorf(status.PreconditionFailed, "couldn't add peer: setup key is invalid") } - err = transaction.IncrementSetupKeyUsage(ctx, setupKeyID) + err = transaction.IncrementSetupKeyUsage(ctx, peerAddConfig.SetupKeyID) if err != nil { return fmt.Errorf("failed to increment setup key usage: %w", err) } @@ -760,6 +826,11 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe return fmt.Errorf("failed to increment network serial: %w", err) } + if ephemeral { + // we should track ephemeral peers to be able to clean them if the peer doesn't sync and isn't marked as connected + am.networkMapController.TrackEphemeralPeer(ctx, newPeer) + } + log.WithContext(ctx).Debugf("Peer %s added to account %s", newPeer.ID, accountID) return nil }) @@ -785,7 +856,7 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe opEvent.TargetID = newPeer.ID opEvent.Meta = newPeer.EventMeta(am.networkMapController.GetDNSDomain(settings)) if !addedByUser { - opEvent.Meta["setup_key_name"] = setupKeyName + opEvent.Meta["setup_key_name"] = peerAddConfig.SetupKeyName } am.StoreEvent(ctx, opEvent.InitiatorID, opEvent.TargetID, opEvent.AccountID, opEvent.Activity, opEvent.Meta) diff --git a/management/server/peer/peer.go b/management/server/peer/peer.go index 2439e8a22..db392ddda 100644 --- a/management/server/peer/peer.go +++ b/management/server/peer/peer.go @@ -24,6 +24,8 @@ type Peer struct { IP net.IP `gorm:"serializer:json"` // uniqueness index per accountID (check migrations) // Meta is a Peer system meta data Meta PeerSystemMeta `gorm:"embedded;embeddedPrefix:meta_"` + // ProxyMeta is metadata related to proxy peers + ProxyMeta ProxyMeta `gorm:"embedded;embeddedPrefix:proxy_meta_"` // Name is peer's name (machine name) Name string `gorm:"index"` // DNSLabel is the parsed peer name for domain resolution. It is used to form an FQDN by appending the account's @@ -48,6 +50,7 @@ type Peer struct { CreatedAt time.Time // Indicate ephemeral peer attribute Ephemeral bool `gorm:"index"` + // Geo location based on connection IP Location Location `gorm:"embedded;embeddedPrefix:location_"` @@ -57,6 +60,11 @@ type Peer struct { AllowExtraDNSLabels bool } +type ProxyMeta struct { + Embedded bool `gorm:"index"` + Cluster string `gorm:"index"` +} + type PeerStatus struct { //nolint:revive // LastSeen is the last time peer was connected to the management service LastSeen time.Time @@ -224,6 +232,7 @@ func (p *Peer) Copy() *Peer { LastLogin: p.LastLogin, CreatedAt: p.CreatedAt, Ephemeral: p.Ephemeral, + ProxyMeta: p.ProxyMeta, Location: p.Location, InactivityExpirationEnabled: p.InactivityExpirationEnabled, ExtraDNSLabels: slices.Clone(p.ExtraDNSLabels), @@ -343,9 +352,10 @@ func (p *Peer) FromAPITemporaryAccessRequest(a *api.PeerTemporaryAccessRequest) p.Name = a.Name p.Key = a.WgPubKey p.Meta = PeerSystemMeta{ - Hostname: a.Name, - GoOS: "js", - OS: "js", + Hostname: a.Name, + GoOS: "js", + OS: "js", + KernelVersion: "wasm", } } diff --git a/management/server/peer_test.go b/management/server/peer_test.go index 3846a3e85..b17757ffd 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -2489,3 +2489,252 @@ func TestLoginPeer_ApprovedUserCanLogin(t *testing.T) { _, _, _, err = manager.LoginPeer(context.Background(), login) require.NoError(t, err, "Regular user should be able to login peers") } + +func TestHandleUserAddedPeer(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err) + + account := newAccountWithId(context.Background(), "test-account", "owner", "", "", "", false) + err = manager.Store.SaveAccount(context.Background(), account) + require.NoError(t, err) + + t.Run("regular user can add peer", func(t *testing.T) { + regularUser := types.NewRegularUser("regular-user-1", "", "") + regularUser.AccountID = account.Id + regularUser.AutoGroups = []string{"group1", "group2"} + err = manager.Store.SaveUser(context.Background(), regularUser) + require.NoError(t, err) + + opEvent := &activity.Event{} + config := &peerAddAuthConfig{} + + err = manager.handleUserAddedPeer(context.Background(), account.Id, regularUser.Id, false, opEvent, config) + require.NoError(t, err) + assert.Equal(t, account.Id, config.AccountID) + assert.Equal(t, regularUser.AutoGroups, config.GroupsToAdd) + assert.Equal(t, regularUser.Id, opEvent.InitiatorID) + assert.Equal(t, activity.PeerAddedByUser, opEvent.Activity) + }) + + t.Run("pending approval user cannot add peer", func(t *testing.T) { + pendingUser := types.NewRegularUser("pending-user", "", "") + pendingUser.AccountID = account.Id + pendingUser.PendingApproval = true + err = manager.Store.SaveUser(context.Background(), pendingUser) + require.NoError(t, err) + + opEvent := &activity.Event{} + config := &peerAddAuthConfig{} + + err = manager.handleUserAddedPeer(context.Background(), account.Id, pendingUser.Id, false, opEvent, config) + require.Error(t, err) + assert.Contains(t, err.Error(), "user pending approval cannot add peers") + }) + + t.Run("user not found", func(t *testing.T) { + opEvent := &activity.Event{} + config := &peerAddAuthConfig{} + + err = manager.handleUserAddedPeer(context.Background(), account.Id, "non-existent-user", false, opEvent, config) + require.Error(t, err) + assert.Contains(t, err.Error(), "user not found") + }) + + t.Run("temporary peer requires permissions", func(t *testing.T) { + regularUser := types.NewRegularUser("regular-user-2", "", "") + regularUser.AccountID = account.Id + err = manager.Store.SaveUser(context.Background(), regularUser) + require.NoError(t, err) + + opEvent := &activity.Event{} + config := &peerAddAuthConfig{} + + // Should fail because user doesn't have permissions for temporary peers + err = manager.handleUserAddedPeer(context.Background(), account.Id, regularUser.Id, true, opEvent, config) + require.Error(t, err) + }) +} + +func TestHandleSetupKeyAddedPeer(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err) + + account := newAccountWithId(context.Background(), "test-account", "owner", "", "", "", false) + err = manager.Store.SaveAccount(context.Background(), account) + require.NoError(t, err) + + // Create admin user for setup key creation + adminUser := types.NewAdminUser("admin-user") + adminUser.AccountID = account.Id + err = manager.Store.SaveUser(context.Background(), adminUser) + require.NoError(t, err) + + t.Run("valid setup key", func(t *testing.T) { + setupKey, err := manager.CreateSetupKey(context.Background(), account.Id, "test-key", types.SetupKeyReusable, time.Hour, []string{}, 0, adminUser.Id, false, false) + require.NoError(t, err) + + upperKey := strings.ToUpper(setupKey.Key) + hashedKey := sha256.Sum256([]byte(upperKey)) + encodedHashedKey := b64.StdEncoding.EncodeToString(hashedKey[:]) + + opEvent := &activity.Event{} + config := &peerAddAuthConfig{} + peer := &nbpeer.Peer{ExtraDNSLabels: []string{}} + + err = manager.handleSetupKeyAddedPeer(context.Background(), encodedHashedKey, peer, opEvent, config) + require.NoError(t, err) + assert.Equal(t, setupKey.Id, config.SetupKeyID) + assert.Equal(t, setupKey.Name, config.SetupKeyName) + assert.Equal(t, setupKey.AutoGroups, config.GroupsToAdd) + assert.Equal(t, setupKey.Ephemeral, config.Ephemeral) + assert.Equal(t, setupKey.Id, opEvent.InitiatorID) + assert.Equal(t, activity.PeerAddedWithSetupKey, opEvent.Activity) + }) + + t.Run("invalid setup key", func(t *testing.T) { + invalidKey := "invalid-key" + hashedKey := sha256.Sum256([]byte(invalidKey)) + encodedHashedKey := b64.StdEncoding.EncodeToString(hashedKey[:]) + + opEvent := &activity.Event{} + config := &peerAddAuthConfig{} + peer := &nbpeer.Peer{} + + err = manager.handleSetupKeyAddedPeer(context.Background(), encodedHashedKey, peer, opEvent, config) + require.Error(t, err) + assert.Contains(t, err.Error(), "setup key is invalid") + }) + + t.Run("expired setup key", func(t *testing.T) { + setupKey, err := manager.CreateSetupKey(context.Background(), account.Id, "expired-key", types.SetupKeyReusable, time.Millisecond, []string{}, 0, adminUser.Id, false, false) + require.NoError(t, err) + + // Wait for key to expire + time.Sleep(10 * time.Millisecond) + + upperKey := strings.ToUpper(setupKey.Key) + hashedKey := sha256.Sum256([]byte(upperKey)) + encodedHashedKey := b64.StdEncoding.EncodeToString(hashedKey[:]) + + opEvent := &activity.Event{} + config := &peerAddAuthConfig{} + peer := &nbpeer.Peer{} + + err = manager.handleSetupKeyAddedPeer(context.Background(), encodedHashedKey, peer, opEvent, config) + require.Error(t, err) + assert.Contains(t, err.Error(), "setup key is invalid") + }) + + t.Run("extra DNS labels not allowed", func(t *testing.T) { + setupKey, err := manager.CreateSetupKey(context.Background(), account.Id, "no-dns-key", types.SetupKeyReusable, time.Hour, []string{}, 0, adminUser.Id, false, false) + require.NoError(t, err) + + upperKey := strings.ToUpper(setupKey.Key) + hashedKey := sha256.Sum256([]byte(upperKey)) + encodedHashedKey := b64.StdEncoding.EncodeToString(hashedKey[:]) + + opEvent := &activity.Event{} + config := &peerAddAuthConfig{} + peer := &nbpeer.Peer{ExtraDNSLabels: []string{"custom.label"}} + + err = manager.handleSetupKeyAddedPeer(context.Background(), encodedHashedKey, peer, opEvent, config) + require.Error(t, err) + assert.Contains(t, err.Error(), "doesn't allow extra DNS labels") + }) + + t.Run("extra DNS labels allowed", func(t *testing.T) { + setupKey, err := manager.CreateSetupKey(context.Background(), account.Id, "dns-key", types.SetupKeyReusable, time.Hour, []string{}, 0, adminUser.Id, false, true) + require.NoError(t, err) + + upperKey := strings.ToUpper(setupKey.Key) + hashedKey := sha256.Sum256([]byte(upperKey)) + encodedHashedKey := b64.StdEncoding.EncodeToString(hashedKey[:]) + + opEvent := &activity.Event{} + config := &peerAddAuthConfig{} + peer := &nbpeer.Peer{ExtraDNSLabels: []string{"custom.label"}} + + err = manager.handleSetupKeyAddedPeer(context.Background(), encodedHashedKey, peer, opEvent, config) + require.NoError(t, err) + assert.True(t, config.AllowExtraDNSLabels) + }) +} + +func TestProcessPeerAddAuth(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err) + + account := newAccountWithId(context.Background(), "test-account", "owner", "", "", "", false) + err = manager.Store.SaveAccount(context.Background(), account) + require.NoError(t, err) + + adminUser := types.NewAdminUser("admin") + adminUser.AccountID = account.Id + err = manager.Store.SaveUser(context.Background(), adminUser) + require.NoError(t, err) + + t.Run("user authentication flow", func(t *testing.T) { + regularUser := types.NewRegularUser("user-auth-test", "", "") + regularUser.AccountID = account.Id + regularUser.AutoGroups = []string{"group1"} + err = manager.Store.SaveUser(context.Background(), regularUser) + require.NoError(t, err) + + opEvent := &activity.Event{Timestamp: time.Now()} + peer := &nbpeer.Peer{Ephemeral: false} + + config, err := manager.processPeerAddAuth(context.Background(), account.Id, regularUser.Id, "", peer, false, true, false, opEvent) + require.NoError(t, err) + assert.Equal(t, account.Id, config.AccountID) + assert.False(t, config.Ephemeral) + assert.Equal(t, regularUser.AutoGroups, config.GroupsToAdd) + assert.Equal(t, account.Id, opEvent.AccountID) + }) + + t.Run("setup key authentication flow", func(t *testing.T) { + setupKey, err := manager.CreateSetupKey(context.Background(), account.Id, "auth-test-key", types.SetupKeyReusable, time.Hour, []string{}, 0, adminUser.Id, true, false) + require.NoError(t, err) + + upperKey := strings.ToUpper(setupKey.Key) + hashedKey := sha256.Sum256([]byte(upperKey)) + encodedHashedKey := b64.StdEncoding.EncodeToString(hashedKey[:]) + + opEvent := &activity.Event{Timestamp: time.Now()} + peer := &nbpeer.Peer{Ephemeral: false} + + config, err := manager.processPeerAddAuth(context.Background(), account.Id, "", encodedHashedKey, peer, false, false, true, opEvent) + require.NoError(t, err) + assert.Equal(t, account.Id, config.AccountID) + assert.True(t, config.Ephemeral) // setupKey.Ephemeral is true + assert.Equal(t, setupKey.AutoGroups, config.GroupsToAdd) + assert.Equal(t, account.Id, opEvent.AccountID) + }) + + t.Run("temporary flag overrides ephemeral", func(t *testing.T) { + regularUser := types.NewRegularUser("temp-user", "", "") + regularUser.AccountID = account.Id + err = manager.Store.SaveUser(context.Background(), regularUser) + require.NoError(t, err) + + opEvent := &activity.Event{Timestamp: time.Now()} + peer := &nbpeer.Peer{Ephemeral: false} + + config, err := manager.processPeerAddAuth(context.Background(), account.Id, regularUser.Id, "", peer, true, true, false, opEvent) + require.Error(t, err) // Will fail permission check but that's expected + _ = config // avoid unused warning + }) + + t.Run("proxy embedded peer (no auth)", func(t *testing.T) { + opEvent := &activity.Event{Timestamp: time.Now()} + peer := &nbpeer.Peer{ + Ephemeral: false, + ProxyMeta: nbpeer.ProxyMeta{Embedded: true}, + } + + config, err := manager.processPeerAddAuth(context.Background(), account.Id, "", "", peer, false, false, false, opEvent) + require.NoError(t, err) + assert.Equal(t, account.Id, config.AccountID) + assert.False(t, config.Ephemeral) + assert.Empty(t, config.GroupsToAdd) + }) +} diff --git a/management/server/permissions/modules/module.go b/management/server/permissions/modules/module.go index f19675d27..93007d4c1 100644 --- a/management/server/permissions/modules/module.go +++ b/management/server/permissions/modules/module.go @@ -3,37 +3,39 @@ package modules type Module string const ( - Networks Module = "networks" - Peers Module = "peers" - RemoteJobs Module = "remote_jobs" - Groups Module = "groups" - Settings Module = "settings" - Accounts Module = "accounts" - Dns Module = "dns" - Nameservers Module = "nameservers" - Events Module = "events" - Policies Module = "policies" - Routes Module = "routes" - Users Module = "users" - SetupKeys Module = "setup_keys" - Pats Module = "pats" + Networks Module = "networks" + Peers Module = "peers" + RemoteJobs Module = "remote_jobs" + Groups Module = "groups" + Settings Module = "settings" + Accounts Module = "accounts" + Dns Module = "dns" + Nameservers Module = "nameservers" + Events Module = "events" + Policies Module = "policies" + Routes Module = "routes" + Users Module = "users" + SetupKeys Module = "setup_keys" + Pats Module = "pats" IdentityProviders Module = "identity_providers" + Services Module = "services" ) var All = map[Module]struct{}{ - Networks: {}, - Peers: {}, - RemoteJobs: {}, - Groups: {}, - Settings: {}, - Accounts: {}, - Dns: {}, - Nameservers: {}, - Events: {}, - Policies: {}, - Routes: {}, - Users: {}, - SetupKeys: {}, - Pats: {}, + Networks: {}, + Peers: {}, + RemoteJobs: {}, + Groups: {}, + Settings: {}, + Accounts: {}, + Dns: {}, + Nameservers: {}, + Events: {}, + Policies: {}, + Routes: {}, + Users: {}, + SetupKeys: {}, + Pats: {}, IdentityProviders: {}, + Services: {}, } diff --git a/management/server/settings/manager.go b/management/server/settings/manager.go index 2b2896572..74af0a3ef 100644 --- a/management/server/settings/manager.go +++ b/management/server/settings/manager.go @@ -24,19 +24,28 @@ type Manager interface { UpdateExtraSettings(ctx context.Context, accountID, userID string, extraSettings *types.ExtraSettings) (bool, error) } +// IdpConfig holds IdP-related configuration that is set at runtime +// and not stored in the database. +type IdpConfig struct { + EmbeddedIdpEnabled bool + LocalAuthDisabled bool +} + type managerImpl struct { store store.Store extraSettingsManager extra_settings.Manager userManager users.Manager permissionsManager permissions.Manager + idpConfig IdpConfig } -func NewManager(store store.Store, userManager users.Manager, extraSettingsManager extra_settings.Manager, permissionsManager permissions.Manager) Manager { +func NewManager(store store.Store, userManager users.Manager, extraSettingsManager extra_settings.Manager, permissionsManager permissions.Manager, idpConfig IdpConfig) Manager { return &managerImpl{ store: store, extraSettingsManager: extraSettingsManager, userManager: userManager, permissionsManager: permissionsManager, + idpConfig: idpConfig, } } @@ -74,6 +83,10 @@ func (m *managerImpl) GetSettings(ctx context.Context, accountID, userID string) settings.Extra.FlowDnsCollectionEnabled = extraSettings.FlowDnsCollectionEnabled } + // Fill in IdP-related runtime settings + settings.EmbeddedIdpEnabled = m.idpConfig.EmbeddedIdpEnabled + settings.LocalAuthDisabled = m.idpConfig.LocalAuthDisabled + return settings, nil } diff --git a/management/server/store/file_store.go b/management/server/store/file_store.go index 8db37ec30..81185b020 100644 --- a/management/server/store/file_store.go +++ b/management/server/store/file_store.go @@ -269,3 +269,8 @@ func (s *FileStore) GetStoreEngine() types.Engine { func (s *FileStore) SetFieldEncrypt(_ *crypt.FieldEncrypt) { // no-op: FileStore stores data in plaintext JSON; encryption is not supported } + +// GetCustomDomainsCounts is a no-op for FileStore as it doesn't support custom domains. +func (s *FileStore) GetCustomDomainsCounts(_ context.Context) (int64, int64, error) { + return 0, 0, nil +} diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 7f48f510e..b3fbfe141 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -18,6 +18,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" + "github.com/rs/xid" log "github.com/sirupsen/logrus" "gorm.io/driver/mysql" "gorm.io/driver/postgres" @@ -27,6 +28,11 @@ import ( "gorm.io/gorm/logger" nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" "github.com/netbirdio/netbird/management/internals/modules/zones" "github.com/netbirdio/netbird/management/internals/modules/zones/records" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" @@ -122,11 +128,13 @@ func NewSqlStore(ctx context.Context, db *gorm.DB, storeEngine types.Engine, met return nil, fmt.Errorf("migratePreAuto: %w", err) } err = db.AutoMigrate( - &types.SetupKey{}, &nbpeer.Peer{}, &types.User{}, &types.PersonalAccessToken{}, &types.Group{}, &types.GroupPeer{}, + &types.SetupKey{}, &nbpeer.Peer{}, &types.User{}, &types.PersonalAccessToken{}, &types.ProxyAccessToken{}, + &types.Group{}, &types.GroupPeer{}, &types.Account{}, &types.Policy{}, &types.PolicyRule{}, &route.Route{}, &nbdns.NameServerGroup{}, &installation{}, &types.ExtraSettings{}, &posture.Checks{}, &nbpeer.NetworkAddress{}, &networkTypes.Network{}, &routerTypes.NetworkRouter{}, &resourceTypes.NetworkResource{}, &types.AccountOnboarding{}, - &types.Job{}, &zones.Zone{}, &records.Record{}, &types.UserInviteRecord{}, + &types.Job{}, &zones.Zone{}, &records.Record{}, &types.UserInviteRecord{}, &rpservice.Service{}, &rpservice.Target{}, &domain.Domain{}, + &accesslogs.AccessLogEntry{}, &proxy.Proxy{}, ) if err != nil { return nil, fmt.Errorf("auto migratePreAuto: %w", err) @@ -1001,6 +1009,18 @@ func (s *SqlStore) GetAccountsCounter(ctx context.Context) (int64, error) { return count, nil } +// GetCustomDomainsCounts returns the total and validated custom domain counts. +func (s *SqlStore) GetCustomDomainsCounts(ctx context.Context) (int64, int64, error) { + var total, validated int64 + if err := s.db.WithContext(ctx).Model(&domain.Domain{}).Count(&total).Error; err != nil { + return 0, 0, err + } + if err := s.db.WithContext(ctx).Model(&domain.Domain{}).Where("validated = ?", true).Count(&validated).Error; err != nil { + return 0, 0, err + } + return total, validated, nil +} + func (s *SqlStore) GetAllAccounts(ctx context.Context) (all []*types.Account) { var accounts []types.Account result := s.db.Find(&accounts) @@ -1094,6 +1114,7 @@ func (s *SqlStore) getAccountGorm(ctx context.Context, accountID string) (*types Preload("NetworkRouters"). Preload("NetworkResources"). Preload("Onboarding"). + Preload("Services.Targets"). Take(&account, idQueryCondition, accountID) if result.Error != nil { log.WithContext(ctx).Errorf("error when getting account %s from the store: %s", accountID, result.Error) @@ -1271,6 +1292,17 @@ func (s *SqlStore) getAccountPgx(ctx context.Context, accountID string) (*types. account.PostureChecks = checks }() + wg.Add(1) + go func() { + defer wg.Done() + services, err := s.getServices(ctx, accountID) + if err != nil { + errChan <- err + return + } + account.Services = services + }() + wg.Add(1) go func() { defer wg.Done() @@ -1672,7 +1704,7 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee meta_kernel_version, meta_network_addresses, meta_system_serial_number, meta_system_product_name, meta_system_manufacturer, meta_environment, meta_flags, meta_files, peer_status_last_seen, peer_status_connected, peer_status_login_expired, peer_status_requires_approval, location_connection_ip, location_country_code, location_city_name, - location_geo_name_id FROM peers WHERE account_id = $1` + location_geo_name_id, proxy_meta_embedded, proxy_meta_cluster FROM peers WHERE account_id = $1` rows, err := s.pool.Query(ctx, query, accountID) if err != nil { return nil, err @@ -1685,12 +1717,12 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee lastLogin, createdAt sql.NullTime sshEnabled, loginExpirationEnabled, inactivityExpirationEnabled, ephemeral, allowExtraDNSLabels sql.NullBool peerStatusLastSeen sql.NullTime - peerStatusConnected, peerStatusLoginExpired, peerStatusRequiresApproval sql.NullBool + peerStatusConnected, peerStatusLoginExpired, peerStatusRequiresApproval, proxyEmbedded sql.NullBool ip, extraDNS, netAddr, env, flags, files, connIP []byte metaHostname, metaGoOS, metaKernel, metaCore, metaPlatform sql.NullString metaOS, metaOSVersion, metaWtVersion, metaUIVersion, metaKernelVersion sql.NullString metaSystemSerialNumber, metaSystemProductName, metaSystemManufacturer sql.NullString - locationCountryCode, locationCityName sql.NullString + locationCountryCode, locationCityName, proxyCluster sql.NullString locationGeoNameID sql.NullInt64 ) @@ -1700,7 +1732,7 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee &metaOS, &metaOSVersion, &metaWtVersion, &metaUIVersion, &metaKernelVersion, &netAddr, &metaSystemSerialNumber, &metaSystemProductName, &metaSystemManufacturer, &env, &flags, &files, &peerStatusLastSeen, &peerStatusConnected, &peerStatusLoginExpired, &peerStatusRequiresApproval, &connIP, - &locationCountryCode, &locationCityName, &locationGeoNameID) + &locationCountryCode, &locationCityName, &locationGeoNameID, &proxyEmbedded, &proxyCluster) if err == nil { if lastLogin.Valid { @@ -1784,6 +1816,12 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee if locationGeoNameID.Valid { p.Location.GeoNameID = uint(locationGeoNameID.Int64) } + if proxyEmbedded.Valid { + p.ProxyMeta.Embedded = proxyEmbedded.Bool + } + if proxyCluster.Valid { + p.ProxyMeta.Cluster = proxyCluster.String + } if ip != nil { _ = json.Unmarshal(ip, &p.IP) } @@ -2039,6 +2077,132 @@ func (s *SqlStore) getPostureChecks(ctx context.Context, accountID string) ([]*p return checks, nil } +func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpservice.Service, error) { + const serviceQuery = `SELECT id, account_id, name, domain, enabled, auth, + meta_created_at, meta_certificate_issued_at, meta_status, proxy_cluster, + pass_host_header, rewrite_redirects, session_private_key, session_public_key + FROM services WHERE account_id = $1` + + const targetsQuery = `SELECT id, account_id, service_id, path, host, port, protocol, + target_id, target_type, enabled + FROM targets WHERE service_id = ANY($1)` + + serviceRows, err := s.pool.Query(ctx, serviceQuery, accountID) + if err != nil { + return nil, err + } + + services, err := pgx.CollectRows(serviceRows, func(row pgx.CollectableRow) (*rpservice.Service, error) { + var s rpservice.Service + var auth []byte + var createdAt, certIssuedAt sql.NullTime + var status, proxyCluster, sessionPrivateKey, sessionPublicKey sql.NullString + err := row.Scan( + &s.ID, + &s.AccountID, + &s.Name, + &s.Domain, + &s.Enabled, + &auth, + &createdAt, + &certIssuedAt, + &status, + &proxyCluster, + &s.PassHostHeader, + &s.RewriteRedirects, + &sessionPrivateKey, + &sessionPublicKey, + ) + if err != nil { + return nil, err + } + + if auth != nil { + if err := json.Unmarshal(auth, &s.Auth); err != nil { + return nil, err + } + } + + s.Meta = rpservice.Meta{} + if createdAt.Valid { + s.Meta.CreatedAt = createdAt.Time + } + if certIssuedAt.Valid { + t := certIssuedAt.Time + s.Meta.CertificateIssuedAt = &t + } + if status.Valid { + s.Meta.Status = status.String + } + if proxyCluster.Valid { + s.ProxyCluster = proxyCluster.String + } + if sessionPrivateKey.Valid { + s.SessionPrivateKey = sessionPrivateKey.String + } + if sessionPublicKey.Valid { + s.SessionPublicKey = sessionPublicKey.String + } + + s.Targets = []*rpservice.Target{} + return &s, nil + }) + if err != nil { + return nil, err + } + + if len(services) == 0 { + return services, nil + } + + serviceIDs := make([]string, len(services)) + serviceMap := make(map[string]*rpservice.Service) + for i, s := range services { + serviceIDs[i] = s.ID + serviceMap[s.ID] = s + } + + targetRows, err := s.pool.Query(ctx, targetsQuery, serviceIDs) + if err != nil { + return nil, err + } + + targets, err := pgx.CollectRows(targetRows, func(row pgx.CollectableRow) (*rpservice.Target, error) { + var t rpservice.Target + var path sql.NullString + err := row.Scan( + &t.ID, + &t.AccountID, + &t.ServiceID, + &path, + &t.Host, + &t.Port, + &t.Protocol, + &t.TargetId, + &t.TargetType, + &t.Enabled, + ) + if err != nil { + return nil, err + } + if path.Valid { + t.Path = &path.String + } + return &t, nil + }) + if err != nil { + return nil, err + } + + for _, target := range targets { + if service, ok := serviceMap[target.ServiceID]; ok { + service.Targets = append(service.Targets, target) + } + } + + return services, nil +} + func (s *SqlStore) getNetworks(ctx context.Context, accountID string) ([]*networkTypes.Network, error) { const query = `SELECT id, account_id, name, description FROM networks WHERE account_id = $1` rows, err := s.pool.Query(ctx, query, accountID) @@ -2566,14 +2730,28 @@ func (s *SqlStore) GetStoreEngine() types.Engine { // NewSqliteStore creates a new SQLite store. func NewSqliteStore(ctx context.Context, dataDir string, metrics telemetry.AppMetrics, skipMigration bool) (*SqlStore, error) { - storeStr := fmt.Sprintf("%s?cache=shared", storeSqliteFileName) - if runtime.GOOS == "windows" { - // Vo avoid `The process cannot access the file because it is being used by another process` on Windows - storeStr = storeSqliteFileName + storeFile := storeSqliteFileName + if envFile, ok := os.LookupEnv("NB_STORE_ENGINE_SQLITE_FILE"); ok && envFile != "" { + storeFile = envFile } - file := filepath.Join(dataDir, storeStr) - db, err := gorm.Open(sqlite.Open(file), getGormConfig()) + // Separate file path from any SQLite URI query parameters (e.g., "store.db?mode=rwc") + filePath, query, hasQuery := strings.Cut(storeFile, "?") + + connStr := filePath + if !filepath.IsAbs(filePath) { + connStr = filepath.Join(dataDir, filePath) + } + + // Append query parameters: user-provided take precedence, otherwise default to cache=shared on non-Windows + if hasQuery { + connStr += "?" + query + } else if runtime.GOOS != "windows" { + // To avoid `The process cannot access the file because it is being used by another process` on Windows + connStr += "?cache=shared" + } + + db, err := gorm.Open(sqlite.Open(connStr), getGormConfig()) if err != nil { return nil, err } @@ -2643,7 +2821,7 @@ func getGormConfig() *gorm.Config { // newPostgresStore initializes a new Postgres store. func newPostgresStore(ctx context.Context, metrics telemetry.AppMetrics, skipMigration bool) (Store, error) { - dsn, ok := os.LookupEnv(postgresDsnEnv) + dsn, ok := lookupDSNEnv(postgresDsnEnv, postgresDsnEnvLegacy) if !ok { return nil, fmt.Errorf("%s is not set", postgresDsnEnv) } @@ -2652,7 +2830,7 @@ func newPostgresStore(ctx context.Context, metrics telemetry.AppMetrics, skipMig // newMysqlStore initializes a new MySQL store. func newMysqlStore(ctx context.Context, metrics telemetry.AppMetrics, skipMigration bool) (Store, error) { - dsn, ok := os.LookupEnv(mysqlDsnEnv) + dsn, ok := lookupDSNEnv(mysqlDsnEnv, mysqlDsnEnvLegacy) if !ok { return nil, fmt.Errorf("%s is not set", mysqlDsnEnv) } @@ -4230,6 +4408,79 @@ func (s *SqlStore) DeletePAT(ctx context.Context, userID, patID string) error { return nil } +// GetProxyAccessTokenByHashedToken retrieves a proxy access token by its hashed value. +func (s *SqlStore) GetProxyAccessTokenByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken types.HashedProxyToken) (*types.ProxyAccessToken, error) { + tx := s.db.WithContext(ctx) + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var token types.ProxyAccessToken + result := tx.Take(&token, "hashed_token = ?", hashedToken) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "proxy access token not found") + } + return nil, status.Errorf(status.Internal, "get proxy access token: %v", result.Error) + } + + return &token, nil +} + +// GetAllProxyAccessTokens retrieves all proxy access tokens. +func (s *SqlStore) GetAllProxyAccessTokens(ctx context.Context, lockStrength LockingStrength) ([]*types.ProxyAccessToken, error) { + tx := s.db.WithContext(ctx) + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var tokens []*types.ProxyAccessToken + result := tx.Find(&tokens) + if result.Error != nil { + return nil, status.Errorf(status.Internal, "get proxy access tokens: %v", result.Error) + } + + return tokens, nil +} + +// SaveProxyAccessToken saves a proxy access token to the database. +func (s *SqlStore) SaveProxyAccessToken(ctx context.Context, token *types.ProxyAccessToken) error { + if result := s.db.WithContext(ctx).Create(token); result.Error != nil { + return status.Errorf(status.Internal, "save proxy access token: %v", result.Error) + } + return nil +} + +// RevokeProxyAccessToken revokes a proxy access token by its ID. +func (s *SqlStore) RevokeProxyAccessToken(ctx context.Context, tokenID string) error { + result := s.db.WithContext(ctx).Model(&types.ProxyAccessToken{}).Where(idQueryCondition, tokenID).Update("revoked", true) + if result.Error != nil { + return status.Errorf(status.Internal, "revoke proxy access token: %v", result.Error) + } + + if result.RowsAffected == 0 { + return status.Errorf(status.NotFound, "proxy access token not found") + } + + return nil +} + +// MarkProxyAccessTokenUsed updates the last used timestamp for a proxy access token. +func (s *SqlStore) MarkProxyAccessTokenUsed(ctx context.Context, tokenID string) error { + result := s.db.WithContext(ctx).Model(&types.ProxyAccessToken{}). + Where(idQueryCondition, tokenID). + Update("last_used", time.Now().UTC()) + if result.Error != nil { + return status.Errorf(status.Internal, "mark proxy access token as used: %v", result.Error) + } + + if result.RowsAffected == 0 { + return status.Errorf(status.NotFound, "proxy access token not found") + } + + return nil +} + func (s *SqlStore) GetPeerByIP(ctx context.Context, lockStrength LockingStrength, accountID string, ip net.IP) (*nbpeer.Peer, error) { tx := s.db if lockStrength != LockingStrengthNone { @@ -4602,3 +4853,609 @@ func (s *SqlStore) GetPeerIDByKey(ctx context.Context, lockStrength LockingStren return peerID, nil } + +func (s *SqlStore) CreateService(ctx context.Context, service *rpservice.Service) error { + serviceCopy := service.Copy() + if err := serviceCopy.EncryptSensitiveData(s.fieldEncrypt); err != nil { + return fmt.Errorf("encrypt service data: %w", err) + } + result := s.db.Create(serviceCopy) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to create service to store: %v", result.Error) + return status.Errorf(status.Internal, "failed to create service to store") + } + + return nil +} + +func (s *SqlStore) UpdateService(ctx context.Context, service *rpservice.Service) error { + serviceCopy := service.Copy() + if err := serviceCopy.EncryptSensitiveData(s.fieldEncrypt); err != nil { + return fmt.Errorf("encrypt service data: %w", err) + } + + // Create target type instance outside transaction to avoid variable shadowing + targetType := &rpservice.Target{} + + // Use a transaction to ensure atomic updates of the service and its targets + err := s.db.Transaction(func(tx *gorm.DB) error { + // Delete existing targets + if err := tx.Where("service_id = ?", serviceCopy.ID).Delete(targetType).Error; err != nil { + return err + } + + // Update the service and create new targets + if err := tx.Session(&gorm.Session{FullSaveAssociations: true}).Save(serviceCopy).Error; err != nil { + return err + } + + return nil + }) + + if err != nil { + log.WithContext(ctx).Errorf("failed to update service to store: %v", err) + return status.Errorf(status.Internal, "failed to update service to store") + } + + return nil +} + +func (s *SqlStore) DeleteService(ctx context.Context, accountID, serviceID string) error { + result := s.db.Delete(&rpservice.Service{}, accountAndIDQueryCondition, accountID, serviceID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete service from store: %v", result.Error) + return status.Errorf(status.Internal, "failed to delete service from store") + } + + if result.RowsAffected == 0 { + return status.Errorf(status.NotFound, "service %s not found", serviceID) + } + + return nil +} + +func (s *SqlStore) DeleteTarget(ctx context.Context, accountID string, serviceID string, targetID uint) error { + result := s.db.Delete(&rpservice.Target{}, "account_id = ? AND service_id = ? AND id = ?", accountID, serviceID, targetID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete target from store: %v", result.Error) + return status.Errorf(status.Internal, "failed to delete target from store") + } + + if result.RowsAffected == 0 { + return status.Errorf(status.NotFound, "target not found for service %s", serviceID) + } + + return nil +} + +func (s *SqlStore) DeleteServiceTargets(ctx context.Context, accountID string, serviceID string) error { + result := s.db.Delete(&rpservice.Target{}, "account_id = ? AND service_id = ?", accountID, serviceID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete targets from store: %v", result.Error) + return status.Errorf(status.Internal, "failed to delete targets from store") + } + + return nil +} + +// GetTargetsByServiceID retrieves all targets for a given service +func (s *SqlStore) GetTargetsByServiceID(ctx context.Context, lockStrength LockingStrength, accountID string, serviceID string) ([]*rpservice.Target, error) { + var targets []*rpservice.Target + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + result := tx.Where("account_id = ? AND service_id = ?", accountID, serviceID).Find(&targets) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get targets from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get targets from store") + } + + return targets, nil +} + +func (s *SqlStore) GetServiceByID(ctx context.Context, lockStrength LockingStrength, accountID, serviceID string) (*rpservice.Service, error) { + tx := s.db.Preload("Targets") + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var service *rpservice.Service + result := tx.Take(&service, accountAndIDQueryCondition, accountID, serviceID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "service %s not found", serviceID) + } + + log.WithContext(ctx).Errorf("failed to get service from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get service from store") + } + + if err := service.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt service data: %w", err) + } + + return service, nil +} + +func (s *SqlStore) GetServiceByDomain(ctx context.Context, domain string) (*rpservice.Service, error) { + var service *rpservice.Service + result := s.db.Preload("Targets").Where("domain = ?", domain).First(&service) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "service with domain %s not found", domain) + } + + log.WithContext(ctx).Errorf("failed to get service by domain from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get service by domain from store") + } + + if err := service.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt service data: %w", err) + } + + return service, nil +} + + +func (s *SqlStore) GetServices(ctx context.Context, lockStrength LockingStrength) ([]*rpservice.Service, error) { + tx := s.db.Preload("Targets") + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var serviceList []*rpservice.Service + result := tx.Find(&serviceList) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get services from the store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get services from store") + } + + for _, service := range serviceList { + if err := service.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt service data: %w", err) + } + } + + return serviceList, nil +} + +func (s *SqlStore) GetAccountServices(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*rpservice.Service, error) { + tx := s.db.Preload("Targets") + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var serviceList []*rpservice.Service + result := tx.Find(&serviceList, accountIDCondition, accountID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get services from the store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get services from store") + } + + for _, service := range serviceList { + if err := service.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt service data: %w", err) + } + } + + return serviceList, nil +} + +// RenewEphemeralService updates the last_renewed_at timestamp for an ephemeral service. +func (s *SqlStore) RenewEphemeralService(ctx context.Context, accountID, peerID, serviceID string) error { + result := s.db.Model(&rpservice.Service{}). + Where("id = ? AND account_id = ? AND source_peer = ? AND source = ?", serviceID, accountID, peerID, rpservice.SourceEphemeral). + Update("meta_last_renewed_at", time.Now()) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to renew ephemeral service: %v", result.Error) + return status.Errorf(status.Internal, "renew ephemeral service") + } + if result.RowsAffected == 0 { + return status.Errorf(status.NotFound, "no active expose session for service %s", serviceID) + } + return nil +} + +// GetExpiredEphemeralServices returns ephemeral services whose last renewal exceeds the given TTL. +// Only the fields needed for reaping are selected. The limit parameter caps the batch size to +// avoid loading too many rows in a single tick. Rows with empty source_peer are excluded to +// skip malformed legacy data. +func (s *SqlStore) GetExpiredEphemeralServices(ctx context.Context, ttl time.Duration, limit int) ([]*rpservice.Service, error) { + cutoff := time.Now().Add(-ttl) + var services []*rpservice.Service + result := s.db. + Select("id", "account_id", "source_peer", "domain"). + Where("source = ? AND source_peer <> '' AND meta_last_renewed_at < ?", rpservice.SourceEphemeral, cutoff). + Limit(limit). + Find(&services) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get expired ephemeral services: %v", result.Error) + return nil, status.Errorf(status.Internal, "get expired ephemeral services") + } + return services, nil +} + +// CountEphemeralServicesByPeer returns the count of ephemeral services for a specific peer. +// Use LockingStrengthUpdate inside a transaction to serialize concurrent create operations. +// The locking is applied via a row-level SELECT ... FOR UPDATE (not on the aggregate) to +// stay compatible with Postgres, which disallows FOR UPDATE on COUNT(*). +func (s *SqlStore) CountEphemeralServicesByPeer(ctx context.Context, lockStrength LockingStrength, accountID, peerID string) (int64, error) { + if lockStrength == LockingStrengthNone { + var count int64 + result := s.db.Model(&rpservice.Service{}). + Where("account_id = ? AND source_peer = ? AND source = ?", accountID, peerID, rpservice.SourceEphemeral). + Count(&count) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to count ephemeral services: %v", result.Error) + return 0, status.Errorf(status.Internal, "count ephemeral services") + } + return count, nil + } + + var ids []string + result := s.db.Model(&rpservice.Service{}). + Clauses(clause.Locking{Strength: string(lockStrength)}). + Select("id"). + Where("account_id = ? AND source_peer = ? AND source = ?", accountID, peerID, rpservice.SourceEphemeral). + Pluck("id", &ids) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to count ephemeral services: %v", result.Error) + return 0, status.Errorf(status.Internal, "count ephemeral services") + } + return int64(len(ids)), nil +} + +// EphemeralServiceExists checks if an ephemeral service exists for the given peer and domain. +// Use LockingStrengthUpdate inside a transaction to serialize concurrent create operations. +func (s *SqlStore) EphemeralServiceExists(ctx context.Context, lockStrength LockingStrength, accountID, peerID, domain string) (bool, error) { + if lockStrength == LockingStrengthNone { + var count int64 + result := s.db.Model(&rpservice.Service{}). + Where("account_id = ? AND source_peer = ? AND domain = ? AND source = ?", accountID, peerID, domain, rpservice.SourceEphemeral). + Count(&count) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to check ephemeral service existence: %v", result.Error) + return false, status.Errorf(status.Internal, "check ephemeral service existence") + } + return count > 0, nil + } + + var id string + result := s.db.Model(&rpservice.Service{}). + Clauses(clause.Locking{Strength: string(lockStrength)}). + Select("id"). + Where("account_id = ? AND source_peer = ? AND domain = ? AND source = ?", accountID, peerID, domain, rpservice.SourceEphemeral). + Limit(1). + Pluck("id", &id) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to check ephemeral service existence: %v", result.Error) + return false, status.Errorf(status.Internal, "check ephemeral service existence") + } + return id != "", nil +} + +// GetServicesByClusterAndPort returns services matching the given proxy cluster, mode, and listen port. +func (s *SqlStore) GetServicesByClusterAndPort(ctx context.Context, lockStrength LockingStrength, proxyCluster string, mode string, listenPort uint16) ([]*rpservice.Service, error) { + tx := s.db.WithContext(ctx) + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var services []*rpservice.Service + result := tx.Where("proxy_cluster = ? AND mode = ? AND listen_port = ?", proxyCluster, mode, listenPort).Find(&services) + if result.Error != nil { + return nil, status.Errorf(status.Internal, "query services by cluster and port") + } + + return services, nil +} + +// GetServicesByCluster returns all services for the given proxy cluster. +func (s *SqlStore) GetServicesByCluster(ctx context.Context, lockStrength LockingStrength, proxyCluster string) ([]*rpservice.Service, error) { + tx := s.db.WithContext(ctx) + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var services []*rpservice.Service + result := tx.Where("proxy_cluster = ?", proxyCluster).Find(&services) + if result.Error != nil { + return nil, status.Errorf(status.Internal, "query services by cluster") + } + return services, nil +} + +func (s *SqlStore) GetCustomDomain(ctx context.Context, accountID string, domainID string) (*domain.Domain, error) { + tx := s.db + + customDomain := &domain.Domain{} + result := tx.Take(&customDomain, accountAndIDQueryCondition, accountID, domainID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "custom domain %s not found", domainID) + } + + log.WithContext(ctx).Errorf("failed to get custom domain from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get custom domain from store") + } + + return customDomain, nil +} + +func (s *SqlStore) ListFreeDomains(ctx context.Context, accountID string) ([]string, error) { + return nil, nil +} + +func (s *SqlStore) ListCustomDomains(ctx context.Context, accountID string) ([]*domain.Domain, error) { + tx := s.db + + var domains []*domain.Domain + result := tx.Find(&domains, accountIDCondition, accountID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get reverse proxy custom domains from the store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get reverse proxy custom domains from store") + } + + return domains, nil +} + +func (s *SqlStore) CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool) (*domain.Domain, error) { + newDomain := &domain.Domain{ + ID: xid.New().String(), // Generate our own ID because gorm doesn't always configure the database to handle this for us. + Domain: domainName, + AccountID: accountID, + TargetCluster: targetCluster, + Type: domain.TypeCustom, + Validated: validated, + } + result := s.db.Create(newDomain) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to create reverse proxy custom domain to store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to create reverse proxy custom domain to store") + } + + return newDomain, nil +} + +func (s *SqlStore) UpdateCustomDomain(ctx context.Context, accountID string, d *domain.Domain) (*domain.Domain, error) { + d.AccountID = accountID + result := s.db.Select("*").Save(d) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to update reverse proxy custom domain to store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to update reverse proxy custom domain to store") + } + + return d, nil +} + +func (s *SqlStore) DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error { + result := s.db.Delete(domain.Domain{}, accountAndIDQueryCondition, accountID, domainID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete reverse proxy custom domain from store: %v", result.Error) + return status.Errorf(status.Internal, "failed to delete reverse proxy custom domain from store") + } + + if result.RowsAffected == 0 { + return status.Errorf(status.NotFound, "reverse proxy custom domain %s not found", domainID) + } + + return nil +} + +// CreateAccessLog creates a new access log entry in the database +func (s *SqlStore) CreateAccessLog(ctx context.Context, logEntry *accesslogs.AccessLogEntry) error { + result := s.db.Create(logEntry) + if result.Error != nil { + log.WithContext(ctx).WithFields(log.Fields{ + "service_id": logEntry.ServiceID, + "method": logEntry.Method, + "host": logEntry.Host, + "path": logEntry.Path, + }).Errorf("failed to create access log entry in store: %v", result.Error) + return status.Errorf(status.Internal, "failed to create access log entry in store") + } + return nil +} + +// GetAccountAccessLogs retrieves access logs for a given account with pagination and filtering +func (s *SqlStore) GetAccountAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) { + var logs []*accesslogs.AccessLogEntry + var totalCount int64 + + baseQuery := s.db.WithContext(ctx). + Model(&accesslogs.AccessLogEntry{}). + Where(accountIDCondition, accountID) + + baseQuery = s.applyAccessLogFilters(baseQuery, filter) + + if err := baseQuery.Count(&totalCount).Error; err != nil { + log.WithContext(ctx).Errorf("failed to count access logs: %v", err) + return nil, 0, status.Errorf(status.Internal, "failed to count access logs") + } + + query := s.db.WithContext(ctx). + Where(accountIDCondition, accountID) + + query = s.applyAccessLogFilters(query, filter) + + sortColumns := filter.GetSortColumn() + sortOrder := strings.ToUpper(filter.GetSortOrder()) + + var orderClauses []string + for _, col := range strings.Split(sortColumns, ",") { + col = strings.TrimSpace(col) + if col != "" { + orderClauses = append(orderClauses, col+" "+sortOrder) + } + } + orderClause := strings.Join(orderClauses, ", ") + + query = query. + Order(orderClause). + Limit(filter.GetLimit()). + Offset(filter.GetOffset()) + + if lockStrength != LockingStrengthNone { + query = query.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + result := query.Find(&logs) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get access logs from store: %v", result.Error) + return nil, 0, status.Errorf(status.Internal, "failed to get access logs from store") + } + + return logs, totalCount, nil +} + +// DeleteOldAccessLogs deletes all access logs older than the specified time +func (s *SqlStore) DeleteOldAccessLogs(ctx context.Context, olderThan time.Time) (int64, error) { + result := s.db.WithContext(ctx). + Where("timestamp < ?", olderThan). + Delete(&accesslogs.AccessLogEntry{}) + + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete old access logs: %v", result.Error) + return 0, status.Errorf(status.Internal, "failed to delete old access logs") + } + + return result.RowsAffected, nil +} + +// applyAccessLogFilters applies filter conditions to the query +func (s *SqlStore) applyAccessLogFilters(query *gorm.DB, filter accesslogs.AccessLogFilter) *gorm.DB { + if filter.Search != nil { + searchPattern := "%" + *filter.Search + "%" + query = query.Where( + "id LIKE ? OR location_connection_ip LIKE ? OR host LIKE ? OR path LIKE ? OR CONCAT(host, path) LIKE ? OR user_id IN (SELECT id FROM users WHERE email LIKE ? OR name LIKE ?)", + searchPattern, searchPattern, searchPattern, searchPattern, searchPattern, searchPattern, searchPattern, + ) + } + + if filter.SourceIP != nil { + query = query.Where("location_connection_ip = ?", *filter.SourceIP) + } + + if filter.Host != nil { + query = query.Where("host = ?", *filter.Host) + } + + if filter.Path != nil { + // Support LIKE pattern for path filtering + query = query.Where("path LIKE ?", "%"+*filter.Path+"%") + } + + if filter.UserID != nil { + query = query.Where("user_id = ?", *filter.UserID) + } + + if filter.Method != nil { + query = query.Where("method = ?", *filter.Method) + } + + if filter.Status != nil { + switch *filter.Status { + case "success": + query = query.Where("status_code >= ? AND status_code < ?", 200, 400) + case "failed": + query = query.Where("status_code < ? OR status_code >= ?", 200, 400) + } + } + + if filter.StatusCode != nil { + query = query.Where("status_code = ?", *filter.StatusCode) + } + + if filter.StartDate != nil { + query = query.Where("timestamp >= ?", *filter.StartDate) + } + + if filter.EndDate != nil { + query = query.Where("timestamp <= ?", *filter.EndDate) + } + + return query +} + +func (s *SqlStore) GetServiceTargetByTargetID(ctx context.Context, lockStrength LockingStrength, accountID string, targetID string) (*rpservice.Target, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var target *rpservice.Target + result := tx.Take(&target, "account_id = ? AND target_id = ?", accountID, targetID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "service target with ID %s not found", targetID) + } + + log.WithContext(ctx).Errorf("failed to get service target from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get service target from store") + } + + return target, nil +} + +// SaveProxy saves or updates a proxy in the database +func (s *SqlStore) SaveProxy(ctx context.Context, p *proxy.Proxy) error { + result := s.db.WithContext(ctx).Save(p) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to save proxy: %v", result.Error) + return status.Errorf(status.Internal, "failed to save proxy") + } + return nil +} + +// UpdateProxyHeartbeat updates the last_seen timestamp for a proxy +func (s *SqlStore) UpdateProxyHeartbeat(ctx context.Context, proxyID string) error { + result := s.db.WithContext(ctx). + Model(&proxy.Proxy{}). + Where("id = ? AND status = ?", proxyID, "connected"). + Update("last_seen", time.Now()) + + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to update proxy heartbeat: %v", result.Error) + return status.Errorf(status.Internal, "failed to update proxy heartbeat") + } + return nil +} + +// GetActiveProxyClusterAddresses returns all unique cluster addresses for active proxies +func (s *SqlStore) GetActiveProxyClusterAddresses(ctx context.Context) ([]string, error) { + var addresses []string + + result := s.db.WithContext(ctx). + Model(&proxy.Proxy{}). + Where("status = ? AND last_seen > ?", "connected", time.Now().Add(-2*time.Minute)). + Distinct("cluster_address"). + Pluck("cluster_address", &addresses) + + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get active proxy cluster addresses: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get active proxy cluster addresses") + } + + return addresses, nil +} + +// CleanupStaleProxies deletes proxies that haven't sent heartbeat in the specified duration +func (s *SqlStore) CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error { + cutoffTime := time.Now().Add(-inactivityDuration) + + result := s.db.WithContext(ctx). + Where("last_seen < ?", cutoffTime). + Delete(&proxy.Proxy{}) + + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to cleanup stale proxies: %v", result.Error) + return status.Errorf(status.Internal, "failed to cleanup stale proxies") + } + + if result.RowsAffected > 0 { + log.WithContext(ctx).Infof("Cleaned up %d stale proxies", result.RowsAffected) + } + + return nil +} diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go index 7cf42c4e8..bafa63580 100644 --- a/management/server/store/sql_store_test.go +++ b/management/server/store/sql_store_test.go @@ -1360,6 +1360,9 @@ func TestSqlStore_GetGroupsByIDs(t *testing.T) { } func TestSqlStore_CreateGroup(t *testing.T) { + if os.Getenv("CI") == "true" { + t.Log("Skipping MySQL test on CI") + } t.Setenv("NETBIRD_STORE_ENGINE", string(types.MysqlStoreEngine)) store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) t.Cleanup(cleanup) diff --git a/management/server/store/sqlstore_bench_test.go b/management/server/store/sqlstore_bench_test.go index 350a1da83..f2abafceb 100644 --- a/management/server/store/sqlstore_bench_test.go +++ b/management/server/store/sqlstore_bench_test.go @@ -20,6 +20,7 @@ import ( "github.com/stretchr/testify/assert" nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" networkTypes "github.com/netbirdio/netbird/management/server/networks/types" @@ -263,7 +264,7 @@ func setupBenchmarkDB(b testing.TB) (*SqlStore, func(), string) { &types.Policy{}, &types.PolicyRule{}, &route.Route{}, &nbdns.NameServerGroup{}, &posture.Checks{}, &networkTypes.Network{}, &routerTypes.NetworkRouter{}, &resourceTypes.NetworkResource{}, - &types.AccountOnboarding{}, + &types.AccountOnboarding{}, &service.Service{}, &service.Target{}, } for i := len(models) - 1; i >= 0; i-- { diff --git a/management/server/store/store.go b/management/server/store/store.go index be0d29768..8bb52f38a 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -1,5 +1,7 @@ package store +//go:generate go run github.com/golang/mock/mockgen -package store -destination=store_mock.go -source=./store.go -build_flags=-mod=mod + import ( "context" "errors" @@ -23,6 +25,10 @@ import ( "gorm.io/gorm" "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" "github.com/netbirdio/netbird/management/internals/modules/zones" "github.com/netbirdio/netbird/management/internals/modules/zones/records" "github.com/netbirdio/netbird/management/server/telemetry" @@ -106,6 +112,12 @@ type Store interface { SavePAT(ctx context.Context, pat *types.PersonalAccessToken) error DeletePAT(ctx context.Context, userID, patID string) error + GetProxyAccessTokenByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken types.HashedProxyToken) (*types.ProxyAccessToken, error) + GetAllProxyAccessTokens(ctx context.Context, lockStrength LockingStrength) ([]*types.ProxyAccessToken, error) + SaveProxyAccessToken(ctx context.Context, token *types.ProxyAccessToken) error + RevokeProxyAccessToken(ctx context.Context, tokenID string) error + MarkProxyAccessTokenUsed(ctx context.Context, tokenID string) error + GetAccountGroups(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.Group, error) GetResourceGroups(ctx context.Context, lockStrength LockingStrength, accountID, resourceID string) ([]*types.Group, error) GetGroupByID(ctx context.Context, lockStrength LockingStrength, accountID, groupID string) (*types.Group, error) @@ -240,13 +252,60 @@ type Store interface { MarkPendingJobsAsFailed(ctx context.Context, accountID, peerID, jobID, reason string) error MarkAllPendingJobsAsFailed(ctx context.Context, accountID, peerID, reason string) error GetPeerIDByKey(ctx context.Context, lockStrength LockingStrength, key string) (string, error) + + CreateService(ctx context.Context, service *rpservice.Service) error + UpdateService(ctx context.Context, service *rpservice.Service) error + DeleteService(ctx context.Context, accountID, serviceID string) error + GetServiceByID(ctx context.Context, lockStrength LockingStrength, accountID, serviceID string) (*rpservice.Service, error) + GetServiceByDomain(ctx context.Context, domain string) (*rpservice.Service, error) + GetServices(ctx context.Context, lockStrength LockingStrength) ([]*rpservice.Service, error) + GetAccountServices(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*rpservice.Service, error) + + RenewEphemeralService(ctx context.Context, accountID, peerID, serviceID string) error + GetExpiredEphemeralServices(ctx context.Context, ttl time.Duration, limit int) ([]*rpservice.Service, error) + CountEphemeralServicesByPeer(ctx context.Context, lockStrength LockingStrength, accountID, peerID string) (int64, error) + EphemeralServiceExists(ctx context.Context, lockStrength LockingStrength, accountID, peerID, domain string) (bool, error) + GetServicesByClusterAndPort(ctx context.Context, lockStrength LockingStrength, proxyCluster string, mode string, listenPort uint16) ([]*rpservice.Service, error) + GetServicesByCluster(ctx context.Context, lockStrength LockingStrength, proxyCluster string) ([]*rpservice.Service, error) + + GetCustomDomain(ctx context.Context, accountID string, domainID string) (*domain.Domain, error) + ListFreeDomains(ctx context.Context, accountID string) ([]string, error) + ListCustomDomains(ctx context.Context, accountID string) ([]*domain.Domain, error) + CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool) (*domain.Domain, error) + UpdateCustomDomain(ctx context.Context, accountID string, d *domain.Domain) (*domain.Domain, error) + DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error + + CreateAccessLog(ctx context.Context, log *accesslogs.AccessLogEntry) error + GetAccountAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) + DeleteOldAccessLogs(ctx context.Context, olderThan time.Time) (int64, error) + GetServiceTargetByTargetID(ctx context.Context, lockStrength LockingStrength, accountID string, targetID string) (*rpservice.Target, error) + GetTargetsByServiceID(ctx context.Context, lockStrength LockingStrength, accountID string, serviceID string) ([]*rpservice.Target, error) + DeleteTarget(ctx context.Context, accountID string, serviceID string, targetID uint) error + DeleteServiceTargets(ctx context.Context, accountID string, serviceID string) error + + SaveProxy(ctx context.Context, proxy *proxy.Proxy) error + UpdateProxyHeartbeat(ctx context.Context, proxyID string) error + GetActiveProxyClusterAddresses(ctx context.Context) ([]string, error) + CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error + + GetCustomDomainsCounts(ctx context.Context) (total int64, validated int64, err error) } const ( - postgresDsnEnv = "NETBIRD_STORE_ENGINE_POSTGRES_DSN" - mysqlDsnEnv = "NETBIRD_STORE_ENGINE_MYSQL_DSN" + postgresDsnEnv = "NB_STORE_ENGINE_POSTGRES_DSN" + postgresDsnEnvLegacy = "NETBIRD_STORE_ENGINE_POSTGRES_DSN" + mysqlDsnEnv = "NB_STORE_ENGINE_MYSQL_DSN" + mysqlDsnEnvLegacy = "NETBIRD_STORE_ENGINE_MYSQL_DSN" ) +// lookupDSNEnv checks the NB_ env var first, then falls back to the legacy NETBIRD_ env var. +func lookupDSNEnv(nbKey, legacyKey string) (string, bool) { + if v, ok := os.LookupEnv(nbKey); ok { + return v, true + } + return os.LookupEnv(legacyKey) +} + var supportedEngines = []types.Engine{types.SqliteStoreEngine, types.PostgresStoreEngine, types.MysqlStoreEngine} func getStoreEngineFromEnv() types.Engine { @@ -531,7 +590,7 @@ func getSqlStoreEngine(ctx context.Context, store *SqlStore, kind types.Engine) } func newReusedPostgresStore(ctx context.Context, store *SqlStore, kind types.Engine) (*SqlStore, func(), error) { - dsn, ok := os.LookupEnv(postgresDsnEnv) + dsn, ok := lookupDSNEnv(postgresDsnEnv, postgresDsnEnvLegacy) if !ok || dsn == "" { var err error _, dsn, err = testutil.CreatePostgresTestContainer() @@ -569,7 +628,7 @@ func newReusedPostgresStore(ctx context.Context, store *SqlStore, kind types.Eng } func newReusedMysqlStore(ctx context.Context, store *SqlStore, kind types.Engine) (*SqlStore, func(), error) { - dsn, ok := os.LookupEnv(mysqlDsnEnv) + dsn, ok := lookupDSNEnv(mysqlDsnEnv, mysqlDsnEnvLegacy) if !ok || dsn == "" { var err error _, dsn, err = testutil.CreateMysqlTestContainer() diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go new file mode 100644 index 000000000..e75e35b94 --- /dev/null +++ b/management/server/store/store_mock.go @@ -0,0 +1,2966 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./store.go + +// Package store is a generated GoMock package. +package store + +import ( + context "context" + net "net" + reflect "reflect" + time "time" + + gomock "github.com/golang/mock/gomock" + dns "github.com/netbirdio/netbird/dns" + accesslogs "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + domain "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" + proxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + service "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + zones "github.com/netbirdio/netbird/management/internals/modules/zones" + records "github.com/netbirdio/netbird/management/internals/modules/zones/records" + types "github.com/netbirdio/netbird/management/server/networks/resources/types" + types0 "github.com/netbirdio/netbird/management/server/networks/routers/types" + types1 "github.com/netbirdio/netbird/management/server/networks/types" + peer "github.com/netbirdio/netbird/management/server/peer" + posture "github.com/netbirdio/netbird/management/server/posture" + types2 "github.com/netbirdio/netbird/management/server/types" + route "github.com/netbirdio/netbird/route" + crypt "github.com/netbirdio/netbird/util/crypt" +) + +// MockStore is a mock of Store interface. +type MockStore struct { + ctrl *gomock.Controller + recorder *MockStoreMockRecorder +} + +// MockStoreMockRecorder is the mock recorder for MockStore. +type MockStoreMockRecorder struct { + mock *MockStore +} + +// NewMockStore creates a new mock instance. +func NewMockStore(ctrl *gomock.Controller) *MockStore { + mock := &MockStore{ctrl: ctrl} + mock.recorder = &MockStoreMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockStore) EXPECT() *MockStoreMockRecorder { + return m.recorder +} + +// AccountExists mocks base method. +func (m *MockStore) AccountExists(ctx context.Context, lockStrength LockingStrength, id string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AccountExists", ctx, lockStrength, id) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AccountExists indicates an expected call of AccountExists. +func (mr *MockStoreMockRecorder) AccountExists(ctx, lockStrength, id interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AccountExists", reflect.TypeOf((*MockStore)(nil).AccountExists), ctx, lockStrength, id) +} + +// AcquireGlobalLock mocks base method. +func (m *MockStore) AcquireGlobalLock(ctx context.Context) func() { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AcquireGlobalLock", ctx) + ret0, _ := ret[0].(func()) + return ret0 +} + +// AcquireGlobalLock indicates an expected call of AcquireGlobalLock. +func (mr *MockStoreMockRecorder) AcquireGlobalLock(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AcquireGlobalLock", reflect.TypeOf((*MockStore)(nil).AcquireGlobalLock), ctx) +} + +// AddPeerToAccount mocks base method. +func (m *MockStore) AddPeerToAccount(ctx context.Context, peer *peer.Peer) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddPeerToAccount", ctx, peer) + ret0, _ := ret[0].(error) + return ret0 +} + +// AddPeerToAccount indicates an expected call of AddPeerToAccount. +func (mr *MockStoreMockRecorder) AddPeerToAccount(ctx, peer interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddPeerToAccount", reflect.TypeOf((*MockStore)(nil).AddPeerToAccount), ctx, peer) +} + +// AddPeerToAllGroup mocks base method. +func (m *MockStore) AddPeerToAllGroup(ctx context.Context, accountID, peerID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddPeerToAllGroup", ctx, accountID, peerID) + ret0, _ := ret[0].(error) + return ret0 +} + +// AddPeerToAllGroup indicates an expected call of AddPeerToAllGroup. +func (mr *MockStoreMockRecorder) AddPeerToAllGroup(ctx, accountID, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddPeerToAllGroup", reflect.TypeOf((*MockStore)(nil).AddPeerToAllGroup), ctx, accountID, peerID) +} + +// AddPeerToGroup mocks base method. +func (m *MockStore) AddPeerToGroup(ctx context.Context, accountID, peerId, groupID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddPeerToGroup", ctx, accountID, peerId, groupID) + ret0, _ := ret[0].(error) + return ret0 +} + +// AddPeerToGroup indicates an expected call of AddPeerToGroup. +func (mr *MockStoreMockRecorder) AddPeerToGroup(ctx, accountID, peerId, groupID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddPeerToGroup", reflect.TypeOf((*MockStore)(nil).AddPeerToGroup), ctx, accountID, peerId, groupID) +} + +// AddResourceToGroup mocks base method. +func (m *MockStore) AddResourceToGroup(ctx context.Context, accountId, groupID string, resource *types2.Resource) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddResourceToGroup", ctx, accountId, groupID, resource) + ret0, _ := ret[0].(error) + return ret0 +} + +// AddResourceToGroup indicates an expected call of AddResourceToGroup. +func (mr *MockStoreMockRecorder) AddResourceToGroup(ctx, accountId, groupID, resource interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddResourceToGroup", reflect.TypeOf((*MockStore)(nil).AddResourceToGroup), ctx, accountId, groupID, resource) +} + +// ApproveAccountPeers mocks base method. +func (m *MockStore) ApproveAccountPeers(ctx context.Context, accountID string) (int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ApproveAccountPeers", ctx, accountID) + ret0, _ := ret[0].(int) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ApproveAccountPeers indicates an expected call of ApproveAccountPeers. +func (mr *MockStoreMockRecorder) ApproveAccountPeers(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ApproveAccountPeers", reflect.TypeOf((*MockStore)(nil).ApproveAccountPeers), ctx, accountID) +} + +// CleanupStaleProxies mocks base method. +func (m *MockStore) CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CleanupStaleProxies", ctx, inactivityDuration) + ret0, _ := ret[0].(error) + return ret0 +} + +// CleanupStaleProxies indicates an expected call of CleanupStaleProxies. +func (mr *MockStoreMockRecorder) CleanupStaleProxies(ctx, inactivityDuration interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CleanupStaleProxies", reflect.TypeOf((*MockStore)(nil).CleanupStaleProxies), ctx, inactivityDuration) +} + +// Close mocks base method. +func (m *MockStore) Close(ctx context.Context) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Close", ctx) + ret0, _ := ret[0].(error) + return ret0 +} + +// Close indicates an expected call of Close. +func (mr *MockStoreMockRecorder) Close(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockStore)(nil).Close), ctx) +} + +// CompletePeerJob mocks base method. +func (m *MockStore) CompletePeerJob(ctx context.Context, job *types2.Job) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CompletePeerJob", ctx, job) + ret0, _ := ret[0].(error) + return ret0 +} + +// CompletePeerJob indicates an expected call of CompletePeerJob. +func (mr *MockStoreMockRecorder) CompletePeerJob(ctx, job interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CompletePeerJob", reflect.TypeOf((*MockStore)(nil).CompletePeerJob), ctx, job) +} + +// CountAccountsByPrivateDomain mocks base method. +func (m *MockStore) CountAccountsByPrivateDomain(ctx context.Context, domain string) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CountAccountsByPrivateDomain", ctx, domain) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CountAccountsByPrivateDomain indicates an expected call of CountAccountsByPrivateDomain. +func (mr *MockStoreMockRecorder) CountAccountsByPrivateDomain(ctx, domain interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountAccountsByPrivateDomain", reflect.TypeOf((*MockStore)(nil).CountAccountsByPrivateDomain), ctx, domain) +} + +// CountEphemeralServicesByPeer mocks base method. +func (m *MockStore) CountEphemeralServicesByPeer(ctx context.Context, lockStrength LockingStrength, accountID, peerID string) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CountEphemeralServicesByPeer", ctx, lockStrength, accountID, peerID) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CountEphemeralServicesByPeer indicates an expected call of CountEphemeralServicesByPeer. +func (mr *MockStoreMockRecorder) CountEphemeralServicesByPeer(ctx, lockStrength, accountID, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountEphemeralServicesByPeer", reflect.TypeOf((*MockStore)(nil).CountEphemeralServicesByPeer), ctx, lockStrength, accountID, peerID) +} + +// CreateAccessLog mocks base method. +func (m *MockStore) CreateAccessLog(ctx context.Context, log *accesslogs.AccessLogEntry) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateAccessLog", ctx, log) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateAccessLog indicates an expected call of CreateAccessLog. +func (mr *MockStoreMockRecorder) CreateAccessLog(ctx, log interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAccessLog", reflect.TypeOf((*MockStore)(nil).CreateAccessLog), ctx, log) +} + +// CreateCustomDomain mocks base method. +func (m *MockStore) CreateCustomDomain(ctx context.Context, accountID, domainName, targetCluster string, validated bool) (*domain.Domain, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateCustomDomain", ctx, accountID, domainName, targetCluster, validated) + ret0, _ := ret[0].(*domain.Domain) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateCustomDomain indicates an expected call of CreateCustomDomain. +func (mr *MockStoreMockRecorder) CreateCustomDomain(ctx, accountID, domainName, targetCluster, validated interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateCustomDomain", reflect.TypeOf((*MockStore)(nil).CreateCustomDomain), ctx, accountID, domainName, targetCluster, validated) +} + +// CreateDNSRecord mocks base method. +func (m *MockStore) CreateDNSRecord(ctx context.Context, record *records.Record) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateDNSRecord", ctx, record) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateDNSRecord indicates an expected call of CreateDNSRecord. +func (mr *MockStoreMockRecorder) CreateDNSRecord(ctx, record interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateDNSRecord", reflect.TypeOf((*MockStore)(nil).CreateDNSRecord), ctx, record) +} + +// CreateGroup mocks base method. +func (m *MockStore) CreateGroup(ctx context.Context, group *types2.Group) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateGroup", ctx, group) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateGroup indicates an expected call of CreateGroup. +func (mr *MockStoreMockRecorder) CreateGroup(ctx, group interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateGroup", reflect.TypeOf((*MockStore)(nil).CreateGroup), ctx, group) +} + +// CreateGroups mocks base method. +func (m *MockStore) CreateGroups(ctx context.Context, accountID string, groups []*types2.Group) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateGroups", ctx, accountID, groups) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateGroups indicates an expected call of CreateGroups. +func (mr *MockStoreMockRecorder) CreateGroups(ctx, accountID, groups interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateGroups", reflect.TypeOf((*MockStore)(nil).CreateGroups), ctx, accountID, groups) +} + +// CreatePeerJob mocks base method. +func (m *MockStore) CreatePeerJob(ctx context.Context, job *types2.Job) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreatePeerJob", ctx, job) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreatePeerJob indicates an expected call of CreatePeerJob. +func (mr *MockStoreMockRecorder) CreatePeerJob(ctx, job interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePeerJob", reflect.TypeOf((*MockStore)(nil).CreatePeerJob), ctx, job) +} + +// CreatePolicy mocks base method. +func (m *MockStore) CreatePolicy(ctx context.Context, policy *types2.Policy) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreatePolicy", ctx, policy) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreatePolicy indicates an expected call of CreatePolicy. +func (mr *MockStoreMockRecorder) CreatePolicy(ctx, policy interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePolicy", reflect.TypeOf((*MockStore)(nil).CreatePolicy), ctx, policy) +} + +// CreateService mocks base method. +func (m *MockStore) CreateService(ctx context.Context, service *service.Service) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateService", ctx, service) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateService indicates an expected call of CreateService. +func (mr *MockStoreMockRecorder) CreateService(ctx, service interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateService", reflect.TypeOf((*MockStore)(nil).CreateService), ctx, service) +} + +// CreateZone mocks base method. +func (m *MockStore) CreateZone(ctx context.Context, zone *zones.Zone) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateZone", ctx, zone) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateZone indicates an expected call of CreateZone. +func (mr *MockStoreMockRecorder) CreateZone(ctx, zone interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateZone", reflect.TypeOf((*MockStore)(nil).CreateZone), ctx, zone) +} + +// DeleteAccount mocks base method. +func (m *MockStore) DeleteAccount(ctx context.Context, account *types2.Account) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAccount", ctx, account) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAccount indicates an expected call of DeleteAccount. +func (mr *MockStoreMockRecorder) DeleteAccount(ctx, account interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAccount", reflect.TypeOf((*MockStore)(nil).DeleteAccount), ctx, account) +} + +// DeleteCustomDomain mocks base method. +func (m *MockStore) DeleteCustomDomain(ctx context.Context, accountID, domainID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteCustomDomain", ctx, accountID, domainID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteCustomDomain indicates an expected call of DeleteCustomDomain. +func (mr *MockStoreMockRecorder) DeleteCustomDomain(ctx, accountID, domainID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCustomDomain", reflect.TypeOf((*MockStore)(nil).DeleteCustomDomain), ctx, accountID, domainID) +} + +// DeleteDNSRecord mocks base method. +func (m *MockStore) DeleteDNSRecord(ctx context.Context, accountID, zoneID, recordID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteDNSRecord", ctx, accountID, zoneID, recordID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteDNSRecord indicates an expected call of DeleteDNSRecord. +func (mr *MockStoreMockRecorder) DeleteDNSRecord(ctx, accountID, zoneID, recordID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteDNSRecord", reflect.TypeOf((*MockStore)(nil).DeleteDNSRecord), ctx, accountID, zoneID, recordID) +} + +// DeleteGroup mocks base method. +func (m *MockStore) DeleteGroup(ctx context.Context, accountID, groupID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteGroup", ctx, accountID, groupID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteGroup indicates an expected call of DeleteGroup. +func (mr *MockStoreMockRecorder) DeleteGroup(ctx, accountID, groupID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteGroup", reflect.TypeOf((*MockStore)(nil).DeleteGroup), ctx, accountID, groupID) +} + +// DeleteGroups mocks base method. +func (m *MockStore) DeleteGroups(ctx context.Context, accountID string, groupIDs []string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteGroups", ctx, accountID, groupIDs) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteGroups indicates an expected call of DeleteGroups. +func (mr *MockStoreMockRecorder) DeleteGroups(ctx, accountID, groupIDs interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteGroups", reflect.TypeOf((*MockStore)(nil).DeleteGroups), ctx, accountID, groupIDs) +} + +// DeleteHashedPAT2TokenIDIndex mocks base method. +func (m *MockStore) DeleteHashedPAT2TokenIDIndex(hashedToken string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteHashedPAT2TokenIDIndex", hashedToken) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteHashedPAT2TokenIDIndex indicates an expected call of DeleteHashedPAT2TokenIDIndex. +func (mr *MockStoreMockRecorder) DeleteHashedPAT2TokenIDIndex(hashedToken interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteHashedPAT2TokenIDIndex", reflect.TypeOf((*MockStore)(nil).DeleteHashedPAT2TokenIDIndex), hashedToken) +} + +// DeleteNameServerGroup mocks base method. +func (m *MockStore) DeleteNameServerGroup(ctx context.Context, accountID, nameServerGroupID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteNameServerGroup", ctx, accountID, nameServerGroupID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteNameServerGroup indicates an expected call of DeleteNameServerGroup. +func (mr *MockStoreMockRecorder) DeleteNameServerGroup(ctx, accountID, nameServerGroupID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteNameServerGroup", reflect.TypeOf((*MockStore)(nil).DeleteNameServerGroup), ctx, accountID, nameServerGroupID) +} + +// DeleteNetwork mocks base method. +func (m *MockStore) DeleteNetwork(ctx context.Context, accountID, networkID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteNetwork", ctx, accountID, networkID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteNetwork indicates an expected call of DeleteNetwork. +func (mr *MockStoreMockRecorder) DeleteNetwork(ctx, accountID, networkID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteNetwork", reflect.TypeOf((*MockStore)(nil).DeleteNetwork), ctx, accountID, networkID) +} + +// DeleteNetworkResource mocks base method. +func (m *MockStore) DeleteNetworkResource(ctx context.Context, accountID, resourceID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteNetworkResource", ctx, accountID, resourceID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteNetworkResource indicates an expected call of DeleteNetworkResource. +func (mr *MockStoreMockRecorder) DeleteNetworkResource(ctx, accountID, resourceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteNetworkResource", reflect.TypeOf((*MockStore)(nil).DeleteNetworkResource), ctx, accountID, resourceID) +} + +// DeleteNetworkRouter mocks base method. +func (m *MockStore) DeleteNetworkRouter(ctx context.Context, accountID, routerID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteNetworkRouter", ctx, accountID, routerID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteNetworkRouter indicates an expected call of DeleteNetworkRouter. +func (mr *MockStoreMockRecorder) DeleteNetworkRouter(ctx, accountID, routerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteNetworkRouter", reflect.TypeOf((*MockStore)(nil).DeleteNetworkRouter), ctx, accountID, routerID) +} + +// DeleteOldAccessLogs mocks base method. +func (m *MockStore) DeleteOldAccessLogs(ctx context.Context, olderThan time.Time) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteOldAccessLogs", ctx, olderThan) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteOldAccessLogs indicates an expected call of DeleteOldAccessLogs. +func (mr *MockStoreMockRecorder) DeleteOldAccessLogs(ctx, olderThan interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOldAccessLogs", reflect.TypeOf((*MockStore)(nil).DeleteOldAccessLogs), ctx, olderThan) +} + +// DeletePAT mocks base method. +func (m *MockStore) DeletePAT(ctx context.Context, userID, patID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeletePAT", ctx, userID, patID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeletePAT indicates an expected call of DeletePAT. +func (mr *MockStoreMockRecorder) DeletePAT(ctx, userID, patID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePAT", reflect.TypeOf((*MockStore)(nil).DeletePAT), ctx, userID, patID) +} + +// DeletePeer mocks base method. +func (m *MockStore) DeletePeer(ctx context.Context, accountID, peerID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeletePeer", ctx, accountID, peerID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeletePeer indicates an expected call of DeletePeer. +func (mr *MockStoreMockRecorder) DeletePeer(ctx, accountID, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePeer", reflect.TypeOf((*MockStore)(nil).DeletePeer), ctx, accountID, peerID) +} + +// DeletePolicy mocks base method. +func (m *MockStore) DeletePolicy(ctx context.Context, accountID, policyID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeletePolicy", ctx, accountID, policyID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeletePolicy indicates an expected call of DeletePolicy. +func (mr *MockStoreMockRecorder) DeletePolicy(ctx, accountID, policyID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePolicy", reflect.TypeOf((*MockStore)(nil).DeletePolicy), ctx, accountID, policyID) +} + +// DeletePostureChecks mocks base method. +func (m *MockStore) DeletePostureChecks(ctx context.Context, accountID, postureChecksID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeletePostureChecks", ctx, accountID, postureChecksID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeletePostureChecks indicates an expected call of DeletePostureChecks. +func (mr *MockStoreMockRecorder) DeletePostureChecks(ctx, accountID, postureChecksID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePostureChecks", reflect.TypeOf((*MockStore)(nil).DeletePostureChecks), ctx, accountID, postureChecksID) +} + +// DeleteRoute mocks base method. +func (m *MockStore) DeleteRoute(ctx context.Context, accountID, routeID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteRoute", ctx, accountID, routeID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteRoute indicates an expected call of DeleteRoute. +func (mr *MockStoreMockRecorder) DeleteRoute(ctx, accountID, routeID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteRoute", reflect.TypeOf((*MockStore)(nil).DeleteRoute), ctx, accountID, routeID) +} + +// DeleteService mocks base method. +func (m *MockStore) DeleteService(ctx context.Context, accountID, serviceID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteService", ctx, accountID, serviceID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteService indicates an expected call of DeleteService. +func (mr *MockStoreMockRecorder) DeleteService(ctx, accountID, serviceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteService", reflect.TypeOf((*MockStore)(nil).DeleteService), ctx, accountID, serviceID) +} + +// DeleteServiceTargets mocks base method. +func (m *MockStore) DeleteServiceTargets(ctx context.Context, accountID, serviceID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteServiceTargets", ctx, accountID, serviceID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteServiceTargets indicates an expected call of DeleteServiceTargets. +func (mr *MockStoreMockRecorder) DeleteServiceTargets(ctx, accountID, serviceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteServiceTargets", reflect.TypeOf((*MockStore)(nil).DeleteServiceTargets), ctx, accountID, serviceID) +} + +// DeleteSetupKey mocks base method. +func (m *MockStore) DeleteSetupKey(ctx context.Context, accountID, keyID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteSetupKey", ctx, accountID, keyID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteSetupKey indicates an expected call of DeleteSetupKey. +func (mr *MockStoreMockRecorder) DeleteSetupKey(ctx, accountID, keyID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteSetupKey", reflect.TypeOf((*MockStore)(nil).DeleteSetupKey), ctx, accountID, keyID) +} + +// DeleteTarget mocks base method. +func (m *MockStore) DeleteTarget(ctx context.Context, accountID, serviceID string, targetID uint) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteTarget", ctx, accountID, serviceID, targetID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteTarget indicates an expected call of DeleteTarget. +func (mr *MockStoreMockRecorder) DeleteTarget(ctx, accountID, serviceID, targetID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTarget", reflect.TypeOf((*MockStore)(nil).DeleteTarget), ctx, accountID, serviceID, targetID) +} + +// DeleteTokenID2UserIDIndex mocks base method. +func (m *MockStore) DeleteTokenID2UserIDIndex(tokenID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteTokenID2UserIDIndex", tokenID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteTokenID2UserIDIndex indicates an expected call of DeleteTokenID2UserIDIndex. +func (mr *MockStoreMockRecorder) DeleteTokenID2UserIDIndex(tokenID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTokenID2UserIDIndex", reflect.TypeOf((*MockStore)(nil).DeleteTokenID2UserIDIndex), tokenID) +} + +// DeleteUser mocks base method. +func (m *MockStore) DeleteUser(ctx context.Context, accountID, userID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteUser", ctx, accountID, userID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteUser indicates an expected call of DeleteUser. +func (mr *MockStoreMockRecorder) DeleteUser(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUser", reflect.TypeOf((*MockStore)(nil).DeleteUser), ctx, accountID, userID) +} + +// DeleteUserInvite mocks base method. +func (m *MockStore) DeleteUserInvite(ctx context.Context, inviteID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteUserInvite", ctx, inviteID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteUserInvite indicates an expected call of DeleteUserInvite. +func (mr *MockStoreMockRecorder) DeleteUserInvite(ctx, inviteID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUserInvite", reflect.TypeOf((*MockStore)(nil).DeleteUserInvite), ctx, inviteID) +} + +// DeleteZone mocks base method. +func (m *MockStore) DeleteZone(ctx context.Context, accountID, zoneID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteZone", ctx, accountID, zoneID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteZone indicates an expected call of DeleteZone. +func (mr *MockStoreMockRecorder) DeleteZone(ctx, accountID, zoneID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteZone", reflect.TypeOf((*MockStore)(nil).DeleteZone), ctx, accountID, zoneID) +} + +// DeleteZoneDNSRecords mocks base method. +func (m *MockStore) DeleteZoneDNSRecords(ctx context.Context, accountID, zoneID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteZoneDNSRecords", ctx, accountID, zoneID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteZoneDNSRecords indicates an expected call of DeleteZoneDNSRecords. +func (mr *MockStoreMockRecorder) DeleteZoneDNSRecords(ctx, accountID, zoneID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteZoneDNSRecords", reflect.TypeOf((*MockStore)(nil).DeleteZoneDNSRecords), ctx, accountID, zoneID) +} + +// EphemeralServiceExists mocks base method. +func (m *MockStore) EphemeralServiceExists(ctx context.Context, lockStrength LockingStrength, accountID, peerID, domain string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "EphemeralServiceExists", ctx, lockStrength, accountID, peerID, domain) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// EphemeralServiceExists indicates an expected call of EphemeralServiceExists. +func (mr *MockStoreMockRecorder) EphemeralServiceExists(ctx, lockStrength, accountID, peerID, domain interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EphemeralServiceExists", reflect.TypeOf((*MockStore)(nil).EphemeralServiceExists), ctx, lockStrength, accountID, peerID, domain) +} + +// ExecuteInTransaction mocks base method. +func (m *MockStore) ExecuteInTransaction(ctx context.Context, f func(Store) error) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ExecuteInTransaction", ctx, f) + ret0, _ := ret[0].(error) + return ret0 +} + +// ExecuteInTransaction indicates an expected call of ExecuteInTransaction. +func (mr *MockStoreMockRecorder) ExecuteInTransaction(ctx, f interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecuteInTransaction", reflect.TypeOf((*MockStore)(nil).ExecuteInTransaction), ctx, f) +} + +// GetAccount mocks base method. +func (m *MockStore) GetAccount(ctx context.Context, accountID string) (*types2.Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccount", ctx, accountID) + ret0, _ := ret[0].(*types2.Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccount indicates an expected call of GetAccount. +func (mr *MockStoreMockRecorder) GetAccount(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccount", reflect.TypeOf((*MockStore)(nil).GetAccount), ctx, accountID) +} + +// GetAccountAccessLogs mocks base method. +func (m *MockStore) GetAccountAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountAccessLogs", ctx, lockStrength, accountID, filter) + ret0, _ := ret[0].([]*accesslogs.AccessLogEntry) + ret1, _ := ret[1].(int64) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetAccountAccessLogs indicates an expected call of GetAccountAccessLogs. +func (mr *MockStoreMockRecorder) GetAccountAccessLogs(ctx, lockStrength, accountID, filter interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAccessLogs", reflect.TypeOf((*MockStore)(nil).GetAccountAccessLogs), ctx, lockStrength, accountID, filter) +} + +// GetAccountByPeerID mocks base method. +func (m *MockStore) GetAccountByPeerID(ctx context.Context, peerID string) (*types2.Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountByPeerID", ctx, peerID) + ret0, _ := ret[0].(*types2.Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountByPeerID indicates an expected call of GetAccountByPeerID. +func (mr *MockStoreMockRecorder) GetAccountByPeerID(ctx, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountByPeerID", reflect.TypeOf((*MockStore)(nil).GetAccountByPeerID), ctx, peerID) +} + +// GetAccountByPeerPubKey mocks base method. +func (m *MockStore) GetAccountByPeerPubKey(ctx context.Context, peerKey string) (*types2.Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountByPeerPubKey", ctx, peerKey) + ret0, _ := ret[0].(*types2.Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountByPeerPubKey indicates an expected call of GetAccountByPeerPubKey. +func (mr *MockStoreMockRecorder) GetAccountByPeerPubKey(ctx, peerKey interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountByPeerPubKey", reflect.TypeOf((*MockStore)(nil).GetAccountByPeerPubKey), ctx, peerKey) +} + +// GetAccountByPrivateDomain mocks base method. +func (m *MockStore) GetAccountByPrivateDomain(ctx context.Context, domain string) (*types2.Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountByPrivateDomain", ctx, domain) + ret0, _ := ret[0].(*types2.Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountByPrivateDomain indicates an expected call of GetAccountByPrivateDomain. +func (mr *MockStoreMockRecorder) GetAccountByPrivateDomain(ctx, domain interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountByPrivateDomain", reflect.TypeOf((*MockStore)(nil).GetAccountByPrivateDomain), ctx, domain) +} + +// GetAccountBySetupKey mocks base method. +func (m *MockStore) GetAccountBySetupKey(ctx context.Context, setupKey string) (*types2.Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountBySetupKey", ctx, setupKey) + ret0, _ := ret[0].(*types2.Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountBySetupKey indicates an expected call of GetAccountBySetupKey. +func (mr *MockStoreMockRecorder) GetAccountBySetupKey(ctx, setupKey interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountBySetupKey", reflect.TypeOf((*MockStore)(nil).GetAccountBySetupKey), ctx, setupKey) +} + +// GetAccountByUser mocks base method. +func (m *MockStore) GetAccountByUser(ctx context.Context, userID string) (*types2.Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountByUser", ctx, userID) + ret0, _ := ret[0].(*types2.Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountByUser indicates an expected call of GetAccountByUser. +func (mr *MockStoreMockRecorder) GetAccountByUser(ctx, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountByUser", reflect.TypeOf((*MockStore)(nil).GetAccountByUser), ctx, userID) +} + +// GetAccountCreatedBy mocks base method. +func (m *MockStore) GetAccountCreatedBy(ctx context.Context, lockStrength LockingStrength, accountID string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountCreatedBy", ctx, lockStrength, accountID) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountCreatedBy indicates an expected call of GetAccountCreatedBy. +func (mr *MockStoreMockRecorder) GetAccountCreatedBy(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountCreatedBy", reflect.TypeOf((*MockStore)(nil).GetAccountCreatedBy), ctx, lockStrength, accountID) +} + +// GetAccountDNSSettings mocks base method. +func (m *MockStore) GetAccountDNSSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*types2.DNSSettings, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountDNSSettings", ctx, lockStrength, accountID) + ret0, _ := ret[0].(*types2.DNSSettings) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountDNSSettings indicates an expected call of GetAccountDNSSettings. +func (mr *MockStoreMockRecorder) GetAccountDNSSettings(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountDNSSettings", reflect.TypeOf((*MockStore)(nil).GetAccountDNSSettings), ctx, lockStrength, accountID) +} + +// GetAccountDomainAndCategory mocks base method. +func (m *MockStore) GetAccountDomainAndCategory(ctx context.Context, lockStrength LockingStrength, accountID string) (string, string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountDomainAndCategory", ctx, lockStrength, accountID) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(string) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetAccountDomainAndCategory indicates an expected call of GetAccountDomainAndCategory. +func (mr *MockStoreMockRecorder) GetAccountDomainAndCategory(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountDomainAndCategory", reflect.TypeOf((*MockStore)(nil).GetAccountDomainAndCategory), ctx, lockStrength, accountID) +} + +// GetAccountGroupPeers mocks base method. +func (m *MockStore) GetAccountGroupPeers(ctx context.Context, lockStrength LockingStrength, accountID string) (map[string]map[string]struct{}, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountGroupPeers", ctx, lockStrength, accountID) + ret0, _ := ret[0].(map[string]map[string]struct{}) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountGroupPeers indicates an expected call of GetAccountGroupPeers. +func (mr *MockStoreMockRecorder) GetAccountGroupPeers(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountGroupPeers", reflect.TypeOf((*MockStore)(nil).GetAccountGroupPeers), ctx, lockStrength, accountID) +} + +// GetAccountGroups mocks base method. +func (m *MockStore) GetAccountGroups(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types2.Group, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountGroups", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*types2.Group) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountGroups indicates an expected call of GetAccountGroups. +func (mr *MockStoreMockRecorder) GetAccountGroups(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountGroups", reflect.TypeOf((*MockStore)(nil).GetAccountGroups), ctx, lockStrength, accountID) +} + +// GetAccountIDByPeerID mocks base method. +func (m *MockStore) GetAccountIDByPeerID(ctx context.Context, lockStrength LockingStrength, peerID string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountIDByPeerID", ctx, lockStrength, peerID) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountIDByPeerID indicates an expected call of GetAccountIDByPeerID. +func (mr *MockStoreMockRecorder) GetAccountIDByPeerID(ctx, lockStrength, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountIDByPeerID", reflect.TypeOf((*MockStore)(nil).GetAccountIDByPeerID), ctx, lockStrength, peerID) +} + +// GetAccountIDByPeerPubKey mocks base method. +func (m *MockStore) GetAccountIDByPeerPubKey(ctx context.Context, peerKey string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountIDByPeerPubKey", ctx, peerKey) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountIDByPeerPubKey indicates an expected call of GetAccountIDByPeerPubKey. +func (mr *MockStoreMockRecorder) GetAccountIDByPeerPubKey(ctx, peerKey interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountIDByPeerPubKey", reflect.TypeOf((*MockStore)(nil).GetAccountIDByPeerPubKey), ctx, peerKey) +} + +// GetAccountIDByPrivateDomain mocks base method. +func (m *MockStore) GetAccountIDByPrivateDomain(ctx context.Context, lockStrength LockingStrength, domain string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountIDByPrivateDomain", ctx, lockStrength, domain) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountIDByPrivateDomain indicates an expected call of GetAccountIDByPrivateDomain. +func (mr *MockStoreMockRecorder) GetAccountIDByPrivateDomain(ctx, lockStrength, domain interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountIDByPrivateDomain", reflect.TypeOf((*MockStore)(nil).GetAccountIDByPrivateDomain), ctx, lockStrength, domain) +} + +// GetAccountIDBySetupKey mocks base method. +func (m *MockStore) GetAccountIDBySetupKey(ctx context.Context, peerKey string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountIDBySetupKey", ctx, peerKey) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountIDBySetupKey indicates an expected call of GetAccountIDBySetupKey. +func (mr *MockStoreMockRecorder) GetAccountIDBySetupKey(ctx, peerKey interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountIDBySetupKey", reflect.TypeOf((*MockStore)(nil).GetAccountIDBySetupKey), ctx, peerKey) +} + +// GetAccountIDByUserID mocks base method. +func (m *MockStore) GetAccountIDByUserID(ctx context.Context, lockStrength LockingStrength, userID string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountIDByUserID", ctx, lockStrength, userID) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountIDByUserID indicates an expected call of GetAccountIDByUserID. +func (mr *MockStoreMockRecorder) GetAccountIDByUserID(ctx, lockStrength, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountIDByUserID", reflect.TypeOf((*MockStore)(nil).GetAccountIDByUserID), ctx, lockStrength, userID) +} + +// GetAccountMeta mocks base method. +func (m *MockStore) GetAccountMeta(ctx context.Context, lockStrength LockingStrength, accountID string) (*types2.AccountMeta, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountMeta", ctx, lockStrength, accountID) + ret0, _ := ret[0].(*types2.AccountMeta) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountMeta indicates an expected call of GetAccountMeta. +func (mr *MockStoreMockRecorder) GetAccountMeta(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountMeta", reflect.TypeOf((*MockStore)(nil).GetAccountMeta), ctx, lockStrength, accountID) +} + +// GetAccountNameServerGroups mocks base method. +func (m *MockStore) GetAccountNameServerGroups(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*dns.NameServerGroup, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountNameServerGroups", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*dns.NameServerGroup) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountNameServerGroups indicates an expected call of GetAccountNameServerGroups. +func (mr *MockStoreMockRecorder) GetAccountNameServerGroups(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountNameServerGroups", reflect.TypeOf((*MockStore)(nil).GetAccountNameServerGroups), ctx, lockStrength, accountID) +} + +// GetAccountNetwork mocks base method. +func (m *MockStore) GetAccountNetwork(ctx context.Context, lockStrength LockingStrength, accountId string) (*types2.Network, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountNetwork", ctx, lockStrength, accountId) + ret0, _ := ret[0].(*types2.Network) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountNetwork indicates an expected call of GetAccountNetwork. +func (mr *MockStoreMockRecorder) GetAccountNetwork(ctx, lockStrength, accountId interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountNetwork", reflect.TypeOf((*MockStore)(nil).GetAccountNetwork), ctx, lockStrength, accountId) +} + +// GetAccountNetworks mocks base method. +func (m *MockStore) GetAccountNetworks(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types1.Network, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountNetworks", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*types1.Network) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountNetworks indicates an expected call of GetAccountNetworks. +func (mr *MockStoreMockRecorder) GetAccountNetworks(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountNetworks", reflect.TypeOf((*MockStore)(nil).GetAccountNetworks), ctx, lockStrength, accountID) +} + +// GetAccountOnboarding mocks base method. +func (m *MockStore) GetAccountOnboarding(ctx context.Context, accountID string) (*types2.AccountOnboarding, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountOnboarding", ctx, accountID) + ret0, _ := ret[0].(*types2.AccountOnboarding) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountOnboarding indicates an expected call of GetAccountOnboarding. +func (mr *MockStoreMockRecorder) GetAccountOnboarding(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountOnboarding", reflect.TypeOf((*MockStore)(nil).GetAccountOnboarding), ctx, accountID) +} + +// GetAccountOwner mocks base method. +func (m *MockStore) GetAccountOwner(ctx context.Context, lockStrength LockingStrength, accountID string) (*types2.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountOwner", ctx, lockStrength, accountID) + ret0, _ := ret[0].(*types2.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountOwner indicates an expected call of GetAccountOwner. +func (mr *MockStoreMockRecorder) GetAccountOwner(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountOwner", reflect.TypeOf((*MockStore)(nil).GetAccountOwner), ctx, lockStrength, accountID) +} + +// GetAccountPeers mocks base method. +func (m *MockStore) GetAccountPeers(ctx context.Context, lockStrength LockingStrength, accountID, nameFilter, ipFilter string) ([]*peer.Peer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountPeers", ctx, lockStrength, accountID, nameFilter, ipFilter) + ret0, _ := ret[0].([]*peer.Peer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountPeers indicates an expected call of GetAccountPeers. +func (mr *MockStoreMockRecorder) GetAccountPeers(ctx, lockStrength, accountID, nameFilter, ipFilter interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountPeers", reflect.TypeOf((*MockStore)(nil).GetAccountPeers), ctx, lockStrength, accountID, nameFilter, ipFilter) +} + +// GetAccountPeersWithExpiration mocks base method. +func (m *MockStore) GetAccountPeersWithExpiration(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*peer.Peer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountPeersWithExpiration", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*peer.Peer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountPeersWithExpiration indicates an expected call of GetAccountPeersWithExpiration. +func (mr *MockStoreMockRecorder) GetAccountPeersWithExpiration(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountPeersWithExpiration", reflect.TypeOf((*MockStore)(nil).GetAccountPeersWithExpiration), ctx, lockStrength, accountID) +} + +// GetAccountPeersWithInactivity mocks base method. +func (m *MockStore) GetAccountPeersWithInactivity(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*peer.Peer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountPeersWithInactivity", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*peer.Peer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountPeersWithInactivity indicates an expected call of GetAccountPeersWithInactivity. +func (mr *MockStoreMockRecorder) GetAccountPeersWithInactivity(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountPeersWithInactivity", reflect.TypeOf((*MockStore)(nil).GetAccountPeersWithInactivity), ctx, lockStrength, accountID) +} + +// GetAccountPolicies mocks base method. +func (m *MockStore) GetAccountPolicies(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types2.Policy, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountPolicies", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*types2.Policy) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountPolicies indicates an expected call of GetAccountPolicies. +func (mr *MockStoreMockRecorder) GetAccountPolicies(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountPolicies", reflect.TypeOf((*MockStore)(nil).GetAccountPolicies), ctx, lockStrength, accountID) +} + +// GetAccountPostureChecks mocks base method. +func (m *MockStore) GetAccountPostureChecks(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*posture.Checks, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountPostureChecks", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*posture.Checks) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountPostureChecks indicates an expected call of GetAccountPostureChecks. +func (mr *MockStoreMockRecorder) GetAccountPostureChecks(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountPostureChecks", reflect.TypeOf((*MockStore)(nil).GetAccountPostureChecks), ctx, lockStrength, accountID) +} + +// GetAccountRoutes mocks base method. +func (m *MockStore) GetAccountRoutes(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*route.Route, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountRoutes", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*route.Route) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountRoutes indicates an expected call of GetAccountRoutes. +func (mr *MockStoreMockRecorder) GetAccountRoutes(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountRoutes", reflect.TypeOf((*MockStore)(nil).GetAccountRoutes), ctx, lockStrength, accountID) +} + +// GetAccountServices mocks base method. +func (m *MockStore) GetAccountServices(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*service.Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountServices", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*service.Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountServices indicates an expected call of GetAccountServices. +func (mr *MockStoreMockRecorder) GetAccountServices(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountServices", reflect.TypeOf((*MockStore)(nil).GetAccountServices), ctx, lockStrength, accountID) +} + +// GetAccountSettings mocks base method. +func (m *MockStore) GetAccountSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*types2.Settings, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountSettings", ctx, lockStrength, accountID) + ret0, _ := ret[0].(*types2.Settings) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountSettings indicates an expected call of GetAccountSettings. +func (mr *MockStoreMockRecorder) GetAccountSettings(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountSettings", reflect.TypeOf((*MockStore)(nil).GetAccountSettings), ctx, lockStrength, accountID) +} + +// GetAccountSetupKeys mocks base method. +func (m *MockStore) GetAccountSetupKeys(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types2.SetupKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountSetupKeys", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*types2.SetupKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountSetupKeys indicates an expected call of GetAccountSetupKeys. +func (mr *MockStoreMockRecorder) GetAccountSetupKeys(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountSetupKeys", reflect.TypeOf((*MockStore)(nil).GetAccountSetupKeys), ctx, lockStrength, accountID) +} + +// GetAccountUserInvites mocks base method. +func (m *MockStore) GetAccountUserInvites(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types2.UserInviteRecord, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountUserInvites", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*types2.UserInviteRecord) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountUserInvites indicates an expected call of GetAccountUserInvites. +func (mr *MockStoreMockRecorder) GetAccountUserInvites(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountUserInvites", reflect.TypeOf((*MockStore)(nil).GetAccountUserInvites), ctx, lockStrength, accountID) +} + +// GetAccountUsers mocks base method. +func (m *MockStore) GetAccountUsers(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types2.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountUsers", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*types2.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountUsers indicates an expected call of GetAccountUsers. +func (mr *MockStoreMockRecorder) GetAccountUsers(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountUsers", reflect.TypeOf((*MockStore)(nil).GetAccountUsers), ctx, lockStrength, accountID) +} + +// GetAccountZones mocks base method. +func (m *MockStore) GetAccountZones(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*zones.Zone, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountZones", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*zones.Zone) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountZones indicates an expected call of GetAccountZones. +func (mr *MockStoreMockRecorder) GetAccountZones(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountZones", reflect.TypeOf((*MockStore)(nil).GetAccountZones), ctx, lockStrength, accountID) +} + +// GetAccountsCounter mocks base method. +func (m *MockStore) GetAccountsCounter(ctx context.Context) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountsCounter", ctx) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountsCounter indicates an expected call of GetAccountsCounter. +func (mr *MockStoreMockRecorder) GetAccountsCounter(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountsCounter", reflect.TypeOf((*MockStore)(nil).GetAccountsCounter), ctx) +} + +// GetActiveProxyClusterAddresses mocks base method. +func (m *MockStore) GetActiveProxyClusterAddresses(ctx context.Context) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetActiveProxyClusterAddresses", ctx) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetActiveProxyClusterAddresses indicates an expected call of GetActiveProxyClusterAddresses. +func (mr *MockStoreMockRecorder) GetActiveProxyClusterAddresses(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveProxyClusterAddresses", reflect.TypeOf((*MockStore)(nil).GetActiveProxyClusterAddresses), ctx) +} + +// GetAllAccounts mocks base method. +func (m *MockStore) GetAllAccounts(ctx context.Context) []*types2.Account { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllAccounts", ctx) + ret0, _ := ret[0].([]*types2.Account) + return ret0 +} + +// GetAllAccounts indicates an expected call of GetAllAccounts. +func (mr *MockStoreMockRecorder) GetAllAccounts(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllAccounts", reflect.TypeOf((*MockStore)(nil).GetAllAccounts), ctx) +} + +// GetAllEphemeralPeers mocks base method. +func (m *MockStore) GetAllEphemeralPeers(ctx context.Context, lockStrength LockingStrength) ([]*peer.Peer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllEphemeralPeers", ctx, lockStrength) + ret0, _ := ret[0].([]*peer.Peer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAllEphemeralPeers indicates an expected call of GetAllEphemeralPeers. +func (mr *MockStoreMockRecorder) GetAllEphemeralPeers(ctx, lockStrength interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllEphemeralPeers", reflect.TypeOf((*MockStore)(nil).GetAllEphemeralPeers), ctx, lockStrength) +} + +// GetAllProxyAccessTokens mocks base method. +func (m *MockStore) GetAllProxyAccessTokens(ctx context.Context, lockStrength LockingStrength) ([]*types2.ProxyAccessToken, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllProxyAccessTokens", ctx, lockStrength) + ret0, _ := ret[0].([]*types2.ProxyAccessToken) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAllProxyAccessTokens indicates an expected call of GetAllProxyAccessTokens. +func (mr *MockStoreMockRecorder) GetAllProxyAccessTokens(ctx, lockStrength interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllProxyAccessTokens", reflect.TypeOf((*MockStore)(nil).GetAllProxyAccessTokens), ctx, lockStrength) +} + +// GetAnyAccountID mocks base method. +func (m *MockStore) GetAnyAccountID(ctx context.Context) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAnyAccountID", ctx) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAnyAccountID indicates an expected call of GetAnyAccountID. +func (mr *MockStoreMockRecorder) GetAnyAccountID(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAnyAccountID", reflect.TypeOf((*MockStore)(nil).GetAnyAccountID), ctx) +} + +// GetCustomDomain mocks base method. +func (m *MockStore) GetCustomDomain(ctx context.Context, accountID, domainID string) (*domain.Domain, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetCustomDomain", ctx, accountID, domainID) + ret0, _ := ret[0].(*domain.Domain) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetCustomDomain indicates an expected call of GetCustomDomain. +func (mr *MockStoreMockRecorder) GetCustomDomain(ctx, accountID, domainID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCustomDomain", reflect.TypeOf((*MockStore)(nil).GetCustomDomain), ctx, accountID, domainID) +} + +// GetCustomDomainsCounts mocks base method. +func (m *MockStore) GetCustomDomainsCounts(ctx context.Context) (int64, int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetCustomDomainsCounts", ctx) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(int64) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetCustomDomainsCounts indicates an expected call of GetCustomDomainsCounts. +func (mr *MockStoreMockRecorder) GetCustomDomainsCounts(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCustomDomainsCounts", reflect.TypeOf((*MockStore)(nil).GetCustomDomainsCounts), ctx) +} + +// GetDNSRecordByID mocks base method. +func (m *MockStore) GetDNSRecordByID(ctx context.Context, lockStrength LockingStrength, accountID, zoneID, recordID string) (*records.Record, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetDNSRecordByID", ctx, lockStrength, accountID, zoneID, recordID) + ret0, _ := ret[0].(*records.Record) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetDNSRecordByID indicates an expected call of GetDNSRecordByID. +func (mr *MockStoreMockRecorder) GetDNSRecordByID(ctx, lockStrength, accountID, zoneID, recordID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDNSRecordByID", reflect.TypeOf((*MockStore)(nil).GetDNSRecordByID), ctx, lockStrength, accountID, zoneID, recordID) +} + +// GetExpiredEphemeralServices mocks base method. +func (m *MockStore) GetExpiredEphemeralServices(ctx context.Context, ttl time.Duration, limit int) ([]*service.Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetExpiredEphemeralServices", ctx, ttl, limit) + ret0, _ := ret[0].([]*service.Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetExpiredEphemeralServices indicates an expected call of GetExpiredEphemeralServices. +func (mr *MockStoreMockRecorder) GetExpiredEphemeralServices(ctx, ttl, limit interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExpiredEphemeralServices", reflect.TypeOf((*MockStore)(nil).GetExpiredEphemeralServices), ctx, ttl, limit) +} + +// GetGroupByID mocks base method. +func (m *MockStore) GetGroupByID(ctx context.Context, lockStrength LockingStrength, accountID, groupID string) (*types2.Group, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGroupByID", ctx, lockStrength, accountID, groupID) + ret0, _ := ret[0].(*types2.Group) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetGroupByID indicates an expected call of GetGroupByID. +func (mr *MockStoreMockRecorder) GetGroupByID(ctx, lockStrength, accountID, groupID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupByID", reflect.TypeOf((*MockStore)(nil).GetGroupByID), ctx, lockStrength, accountID, groupID) +} + +// GetGroupByName mocks base method. +func (m *MockStore) GetGroupByName(ctx context.Context, lockStrength LockingStrength, groupName, accountID string) (*types2.Group, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGroupByName", ctx, lockStrength, groupName, accountID) + ret0, _ := ret[0].(*types2.Group) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetGroupByName indicates an expected call of GetGroupByName. +func (mr *MockStoreMockRecorder) GetGroupByName(ctx, lockStrength, groupName, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupByName", reflect.TypeOf((*MockStore)(nil).GetGroupByName), ctx, lockStrength, groupName, accountID) +} + +// GetGroupsByIDs mocks base method. +func (m *MockStore) GetGroupsByIDs(ctx context.Context, lockStrength LockingStrength, accountID string, groupIDs []string) (map[string]*types2.Group, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGroupsByIDs", ctx, lockStrength, accountID, groupIDs) + ret0, _ := ret[0].(map[string]*types2.Group) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetGroupsByIDs indicates an expected call of GetGroupsByIDs. +func (mr *MockStoreMockRecorder) GetGroupsByIDs(ctx, lockStrength, accountID, groupIDs interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupsByIDs", reflect.TypeOf((*MockStore)(nil).GetGroupsByIDs), ctx, lockStrength, accountID, groupIDs) +} + +// GetInstallationID mocks base method. +func (m *MockStore) GetInstallationID() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetInstallationID") + ret0, _ := ret[0].(string) + return ret0 +} + +// GetInstallationID indicates an expected call of GetInstallationID. +func (mr *MockStoreMockRecorder) GetInstallationID() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInstallationID", reflect.TypeOf((*MockStore)(nil).GetInstallationID)) +} + +// GetNameServerGroupByID mocks base method. +func (m *MockStore) GetNameServerGroupByID(ctx context.Context, lockStrength LockingStrength, nameServerGroupID, accountID string) (*dns.NameServerGroup, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNameServerGroupByID", ctx, lockStrength, nameServerGroupID, accountID) + ret0, _ := ret[0].(*dns.NameServerGroup) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNameServerGroupByID indicates an expected call of GetNameServerGroupByID. +func (mr *MockStoreMockRecorder) GetNameServerGroupByID(ctx, lockStrength, nameServerGroupID, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNameServerGroupByID", reflect.TypeOf((*MockStore)(nil).GetNameServerGroupByID), ctx, lockStrength, nameServerGroupID, accountID) +} + +// GetNetworkByID mocks base method. +func (m *MockStore) GetNetworkByID(ctx context.Context, lockStrength LockingStrength, accountID, networkID string) (*types1.Network, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNetworkByID", ctx, lockStrength, accountID, networkID) + ret0, _ := ret[0].(*types1.Network) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNetworkByID indicates an expected call of GetNetworkByID. +func (mr *MockStoreMockRecorder) GetNetworkByID(ctx, lockStrength, accountID, networkID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkByID", reflect.TypeOf((*MockStore)(nil).GetNetworkByID), ctx, lockStrength, accountID, networkID) +} + +// GetNetworkResourceByID mocks base method. +func (m *MockStore) GetNetworkResourceByID(ctx context.Context, lockStrength LockingStrength, accountID, resourceID string) (*types.NetworkResource, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNetworkResourceByID", ctx, lockStrength, accountID, resourceID) + ret0, _ := ret[0].(*types.NetworkResource) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNetworkResourceByID indicates an expected call of GetNetworkResourceByID. +func (mr *MockStoreMockRecorder) GetNetworkResourceByID(ctx, lockStrength, accountID, resourceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkResourceByID", reflect.TypeOf((*MockStore)(nil).GetNetworkResourceByID), ctx, lockStrength, accountID, resourceID) +} + +// GetNetworkResourceByName mocks base method. +func (m *MockStore) GetNetworkResourceByName(ctx context.Context, lockStrength LockingStrength, accountID, resourceName string) (*types.NetworkResource, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNetworkResourceByName", ctx, lockStrength, accountID, resourceName) + ret0, _ := ret[0].(*types.NetworkResource) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNetworkResourceByName indicates an expected call of GetNetworkResourceByName. +func (mr *MockStoreMockRecorder) GetNetworkResourceByName(ctx, lockStrength, accountID, resourceName interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkResourceByName", reflect.TypeOf((*MockStore)(nil).GetNetworkResourceByName), ctx, lockStrength, accountID, resourceName) +} + +// GetNetworkResourcesByAccountID mocks base method. +func (m *MockStore) GetNetworkResourcesByAccountID(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.NetworkResource, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNetworkResourcesByAccountID", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*types.NetworkResource) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNetworkResourcesByAccountID indicates an expected call of GetNetworkResourcesByAccountID. +func (mr *MockStoreMockRecorder) GetNetworkResourcesByAccountID(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkResourcesByAccountID", reflect.TypeOf((*MockStore)(nil).GetNetworkResourcesByAccountID), ctx, lockStrength, accountID) +} + +// GetNetworkResourcesByNetID mocks base method. +func (m *MockStore) GetNetworkResourcesByNetID(ctx context.Context, lockStrength LockingStrength, accountID, netID string) ([]*types.NetworkResource, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNetworkResourcesByNetID", ctx, lockStrength, accountID, netID) + ret0, _ := ret[0].([]*types.NetworkResource) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNetworkResourcesByNetID indicates an expected call of GetNetworkResourcesByNetID. +func (mr *MockStoreMockRecorder) GetNetworkResourcesByNetID(ctx, lockStrength, accountID, netID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkResourcesByNetID", reflect.TypeOf((*MockStore)(nil).GetNetworkResourcesByNetID), ctx, lockStrength, accountID, netID) +} + +// GetNetworkRouterByID mocks base method. +func (m *MockStore) GetNetworkRouterByID(ctx context.Context, lockStrength LockingStrength, accountID, routerID string) (*types0.NetworkRouter, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNetworkRouterByID", ctx, lockStrength, accountID, routerID) + ret0, _ := ret[0].(*types0.NetworkRouter) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNetworkRouterByID indicates an expected call of GetNetworkRouterByID. +func (mr *MockStoreMockRecorder) GetNetworkRouterByID(ctx, lockStrength, accountID, routerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkRouterByID", reflect.TypeOf((*MockStore)(nil).GetNetworkRouterByID), ctx, lockStrength, accountID, routerID) +} + +// GetNetworkRoutersByAccountID mocks base method. +func (m *MockStore) GetNetworkRoutersByAccountID(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types0.NetworkRouter, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNetworkRoutersByAccountID", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*types0.NetworkRouter) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNetworkRoutersByAccountID indicates an expected call of GetNetworkRoutersByAccountID. +func (mr *MockStoreMockRecorder) GetNetworkRoutersByAccountID(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkRoutersByAccountID", reflect.TypeOf((*MockStore)(nil).GetNetworkRoutersByAccountID), ctx, lockStrength, accountID) +} + +// GetNetworkRoutersByNetID mocks base method. +func (m *MockStore) GetNetworkRoutersByNetID(ctx context.Context, lockStrength LockingStrength, accountID, netID string) ([]*types0.NetworkRouter, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNetworkRoutersByNetID", ctx, lockStrength, accountID, netID) + ret0, _ := ret[0].([]*types0.NetworkRouter) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNetworkRoutersByNetID indicates an expected call of GetNetworkRoutersByNetID. +func (mr *MockStoreMockRecorder) GetNetworkRoutersByNetID(ctx, lockStrength, accountID, netID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkRoutersByNetID", reflect.TypeOf((*MockStore)(nil).GetNetworkRoutersByNetID), ctx, lockStrength, accountID, netID) +} + +// GetPATByHashedToken mocks base method. +func (m *MockStore) GetPATByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken string) (*types2.PersonalAccessToken, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPATByHashedToken", ctx, lockStrength, hashedToken) + ret0, _ := ret[0].(*types2.PersonalAccessToken) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPATByHashedToken indicates an expected call of GetPATByHashedToken. +func (mr *MockStoreMockRecorder) GetPATByHashedToken(ctx, lockStrength, hashedToken interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPATByHashedToken", reflect.TypeOf((*MockStore)(nil).GetPATByHashedToken), ctx, lockStrength, hashedToken) +} + +// GetPATByID mocks base method. +func (m *MockStore) GetPATByID(ctx context.Context, lockStrength LockingStrength, userID, patID string) (*types2.PersonalAccessToken, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPATByID", ctx, lockStrength, userID, patID) + ret0, _ := ret[0].(*types2.PersonalAccessToken) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPATByID indicates an expected call of GetPATByID. +func (mr *MockStoreMockRecorder) GetPATByID(ctx, lockStrength, userID, patID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPATByID", reflect.TypeOf((*MockStore)(nil).GetPATByID), ctx, lockStrength, userID, patID) +} + +// GetPeerByID mocks base method. +func (m *MockStore) GetPeerByID(ctx context.Context, lockStrength LockingStrength, accountID, peerID string) (*peer.Peer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerByID", ctx, lockStrength, accountID, peerID) + ret0, _ := ret[0].(*peer.Peer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeerByID indicates an expected call of GetPeerByID. +func (mr *MockStoreMockRecorder) GetPeerByID(ctx, lockStrength, accountID, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerByID", reflect.TypeOf((*MockStore)(nil).GetPeerByID), ctx, lockStrength, accountID, peerID) +} + +// GetPeerByIP mocks base method. +func (m *MockStore) GetPeerByIP(ctx context.Context, lockStrength LockingStrength, accountID string, ip net.IP) (*peer.Peer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerByIP", ctx, lockStrength, accountID, ip) + ret0, _ := ret[0].(*peer.Peer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeerByIP indicates an expected call of GetPeerByIP. +func (mr *MockStoreMockRecorder) GetPeerByIP(ctx, lockStrength, accountID, ip interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerByIP", reflect.TypeOf((*MockStore)(nil).GetPeerByIP), ctx, lockStrength, accountID, ip) +} + +// GetPeerByPeerPubKey mocks base method. +func (m *MockStore) GetPeerByPeerPubKey(ctx context.Context, lockStrength LockingStrength, peerKey string) (*peer.Peer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerByPeerPubKey", ctx, lockStrength, peerKey) + ret0, _ := ret[0].(*peer.Peer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeerByPeerPubKey indicates an expected call of GetPeerByPeerPubKey. +func (mr *MockStoreMockRecorder) GetPeerByPeerPubKey(ctx, lockStrength, peerKey interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerByPeerPubKey", reflect.TypeOf((*MockStore)(nil).GetPeerByPeerPubKey), ctx, lockStrength, peerKey) +} + +// GetPeerGroupIDs mocks base method. +func (m *MockStore) GetPeerGroupIDs(ctx context.Context, lockStrength LockingStrength, accountId, peerId string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerGroupIDs", ctx, lockStrength, accountId, peerId) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeerGroupIDs indicates an expected call of GetPeerGroupIDs. +func (mr *MockStoreMockRecorder) GetPeerGroupIDs(ctx, lockStrength, accountId, peerId interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerGroupIDs", reflect.TypeOf((*MockStore)(nil).GetPeerGroupIDs), ctx, lockStrength, accountId, peerId) +} + +// GetPeerGroups mocks base method. +func (m *MockStore) GetPeerGroups(ctx context.Context, lockStrength LockingStrength, accountId, peerId string) ([]*types2.Group, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerGroups", ctx, lockStrength, accountId, peerId) + ret0, _ := ret[0].([]*types2.Group) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeerGroups indicates an expected call of GetPeerGroups. +func (mr *MockStoreMockRecorder) GetPeerGroups(ctx, lockStrength, accountId, peerId interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerGroups", reflect.TypeOf((*MockStore)(nil).GetPeerGroups), ctx, lockStrength, accountId, peerId) +} + +// GetPeerIDByKey mocks base method. +func (m *MockStore) GetPeerIDByKey(ctx context.Context, lockStrength LockingStrength, key string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerIDByKey", ctx, lockStrength, key) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeerIDByKey indicates an expected call of GetPeerIDByKey. +func (mr *MockStoreMockRecorder) GetPeerIDByKey(ctx, lockStrength, key interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerIDByKey", reflect.TypeOf((*MockStore)(nil).GetPeerIDByKey), ctx, lockStrength, key) +} + +// GetPeerIdByLabel mocks base method. +func (m *MockStore) GetPeerIdByLabel(ctx context.Context, lockStrength LockingStrength, accountID, hostname string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerIdByLabel", ctx, lockStrength, accountID, hostname) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeerIdByLabel indicates an expected call of GetPeerIdByLabel. +func (mr *MockStoreMockRecorder) GetPeerIdByLabel(ctx, lockStrength, accountID, hostname interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerIdByLabel", reflect.TypeOf((*MockStore)(nil).GetPeerIdByLabel), ctx, lockStrength, accountID, hostname) +} + +// GetPeerJobByID mocks base method. +func (m *MockStore) GetPeerJobByID(ctx context.Context, accountID, jobID string) (*types2.Job, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerJobByID", ctx, accountID, jobID) + ret0, _ := ret[0].(*types2.Job) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeerJobByID indicates an expected call of GetPeerJobByID. +func (mr *MockStoreMockRecorder) GetPeerJobByID(ctx, accountID, jobID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerJobByID", reflect.TypeOf((*MockStore)(nil).GetPeerJobByID), ctx, accountID, jobID) +} + +// GetPeerJobs mocks base method. +func (m *MockStore) GetPeerJobs(ctx context.Context, accountID, peerID string) ([]*types2.Job, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerJobs", ctx, accountID, peerID) + ret0, _ := ret[0].([]*types2.Job) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeerJobs indicates an expected call of GetPeerJobs. +func (mr *MockStoreMockRecorder) GetPeerJobs(ctx, accountID, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerJobs", reflect.TypeOf((*MockStore)(nil).GetPeerJobs), ctx, accountID, peerID) +} + +// GetPeerLabelsInAccount mocks base method. +func (m *MockStore) GetPeerLabelsInAccount(ctx context.Context, lockStrength LockingStrength, accountId, hostname string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerLabelsInAccount", ctx, lockStrength, accountId, hostname) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeerLabelsInAccount indicates an expected call of GetPeerLabelsInAccount. +func (mr *MockStoreMockRecorder) GetPeerLabelsInAccount(ctx, lockStrength, accountId, hostname interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerLabelsInAccount", reflect.TypeOf((*MockStore)(nil).GetPeerLabelsInAccount), ctx, lockStrength, accountId, hostname) +} + +// GetPeersByGroupIDs mocks base method. +func (m *MockStore) GetPeersByGroupIDs(ctx context.Context, accountID string, groupIDs []string) ([]*peer.Peer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeersByGroupIDs", ctx, accountID, groupIDs) + ret0, _ := ret[0].([]*peer.Peer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeersByGroupIDs indicates an expected call of GetPeersByGroupIDs. +func (mr *MockStoreMockRecorder) GetPeersByGroupIDs(ctx, accountID, groupIDs interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeersByGroupIDs", reflect.TypeOf((*MockStore)(nil).GetPeersByGroupIDs), ctx, accountID, groupIDs) +} + +// GetPeersByIDs mocks base method. +func (m *MockStore) GetPeersByIDs(ctx context.Context, lockStrength LockingStrength, accountID string, peerIDs []string) (map[string]*peer.Peer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeersByIDs", ctx, lockStrength, accountID, peerIDs) + ret0, _ := ret[0].(map[string]*peer.Peer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeersByIDs indicates an expected call of GetPeersByIDs. +func (mr *MockStoreMockRecorder) GetPeersByIDs(ctx, lockStrength, accountID, peerIDs interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeersByIDs", reflect.TypeOf((*MockStore)(nil).GetPeersByIDs), ctx, lockStrength, accountID, peerIDs) +} + +// GetPolicyByID mocks base method. +func (m *MockStore) GetPolicyByID(ctx context.Context, lockStrength LockingStrength, accountID, policyID string) (*types2.Policy, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPolicyByID", ctx, lockStrength, accountID, policyID) + ret0, _ := ret[0].(*types2.Policy) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPolicyByID indicates an expected call of GetPolicyByID. +func (mr *MockStoreMockRecorder) GetPolicyByID(ctx, lockStrength, accountID, policyID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPolicyByID", reflect.TypeOf((*MockStore)(nil).GetPolicyByID), ctx, lockStrength, accountID, policyID) +} + +// GetPolicyRulesByResourceID mocks base method. +func (m *MockStore) GetPolicyRulesByResourceID(ctx context.Context, lockStrength LockingStrength, accountID, peerID string) ([]*types2.PolicyRule, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPolicyRulesByResourceID", ctx, lockStrength, accountID, peerID) + ret0, _ := ret[0].([]*types2.PolicyRule) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPolicyRulesByResourceID indicates an expected call of GetPolicyRulesByResourceID. +func (mr *MockStoreMockRecorder) GetPolicyRulesByResourceID(ctx, lockStrength, accountID, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPolicyRulesByResourceID", reflect.TypeOf((*MockStore)(nil).GetPolicyRulesByResourceID), ctx, lockStrength, accountID, peerID) +} + +// GetPostureCheckByChecksDefinition mocks base method. +func (m *MockStore) GetPostureCheckByChecksDefinition(accountID string, checks *posture.ChecksDefinition) (*posture.Checks, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPostureCheckByChecksDefinition", accountID, checks) + ret0, _ := ret[0].(*posture.Checks) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPostureCheckByChecksDefinition indicates an expected call of GetPostureCheckByChecksDefinition. +func (mr *MockStoreMockRecorder) GetPostureCheckByChecksDefinition(accountID, checks interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPostureCheckByChecksDefinition", reflect.TypeOf((*MockStore)(nil).GetPostureCheckByChecksDefinition), accountID, checks) +} + +// GetPostureChecksByID mocks base method. +func (m *MockStore) GetPostureChecksByID(ctx context.Context, lockStrength LockingStrength, accountID, postureCheckID string) (*posture.Checks, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPostureChecksByID", ctx, lockStrength, accountID, postureCheckID) + ret0, _ := ret[0].(*posture.Checks) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPostureChecksByID indicates an expected call of GetPostureChecksByID. +func (mr *MockStoreMockRecorder) GetPostureChecksByID(ctx, lockStrength, accountID, postureCheckID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPostureChecksByID", reflect.TypeOf((*MockStore)(nil).GetPostureChecksByID), ctx, lockStrength, accountID, postureCheckID) +} + +// GetPostureChecksByIDs mocks base method. +func (m *MockStore) GetPostureChecksByIDs(ctx context.Context, lockStrength LockingStrength, accountID string, postureChecksIDs []string) (map[string]*posture.Checks, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPostureChecksByIDs", ctx, lockStrength, accountID, postureChecksIDs) + ret0, _ := ret[0].(map[string]*posture.Checks) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPostureChecksByIDs indicates an expected call of GetPostureChecksByIDs. +func (mr *MockStoreMockRecorder) GetPostureChecksByIDs(ctx, lockStrength, accountID, postureChecksIDs interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPostureChecksByIDs", reflect.TypeOf((*MockStore)(nil).GetPostureChecksByIDs), ctx, lockStrength, accountID, postureChecksIDs) +} + +// GetProxyAccessTokenByHashedToken mocks base method. +func (m *MockStore) GetProxyAccessTokenByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken types2.HashedProxyToken) (*types2.ProxyAccessToken, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetProxyAccessTokenByHashedToken", ctx, lockStrength, hashedToken) + ret0, _ := ret[0].(*types2.ProxyAccessToken) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetProxyAccessTokenByHashedToken indicates an expected call of GetProxyAccessTokenByHashedToken. +func (mr *MockStoreMockRecorder) GetProxyAccessTokenByHashedToken(ctx, lockStrength, hashedToken interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProxyAccessTokenByHashedToken", reflect.TypeOf((*MockStore)(nil).GetProxyAccessTokenByHashedToken), ctx, lockStrength, hashedToken) +} + +// GetResourceGroups mocks base method. +func (m *MockStore) GetResourceGroups(ctx context.Context, lockStrength LockingStrength, accountID, resourceID string) ([]*types2.Group, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetResourceGroups", ctx, lockStrength, accountID, resourceID) + ret0, _ := ret[0].([]*types2.Group) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetResourceGroups indicates an expected call of GetResourceGroups. +func (mr *MockStoreMockRecorder) GetResourceGroups(ctx, lockStrength, accountID, resourceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetResourceGroups", reflect.TypeOf((*MockStore)(nil).GetResourceGroups), ctx, lockStrength, accountID, resourceID) +} + +// GetRouteByID mocks base method. +func (m *MockStore) GetRouteByID(ctx context.Context, lockStrength LockingStrength, accountID, routeID string) (*route.Route, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetRouteByID", ctx, lockStrength, accountID, routeID) + ret0, _ := ret[0].(*route.Route) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetRouteByID indicates an expected call of GetRouteByID. +func (mr *MockStoreMockRecorder) GetRouteByID(ctx, lockStrength, accountID, routeID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRouteByID", reflect.TypeOf((*MockStore)(nil).GetRouteByID), ctx, lockStrength, accountID, routeID) +} + +// GetServiceByDomain mocks base method. +func (m *MockStore) GetServiceByDomain(ctx context.Context, domain string) (*service.Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetServiceByDomain", ctx, domain) + ret0, _ := ret[0].(*service.Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetServiceByDomain indicates an expected call of GetServiceByDomain. +func (mr *MockStoreMockRecorder) GetServiceByDomain(ctx, domain interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServiceByDomain", reflect.TypeOf((*MockStore)(nil).GetServiceByDomain), ctx, domain) +} + +// GetServiceByID mocks base method. +func (m *MockStore) GetServiceByID(ctx context.Context, lockStrength LockingStrength, accountID, serviceID string) (*service.Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetServiceByID", ctx, lockStrength, accountID, serviceID) + ret0, _ := ret[0].(*service.Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetServiceByID indicates an expected call of GetServiceByID. +func (mr *MockStoreMockRecorder) GetServiceByID(ctx, lockStrength, accountID, serviceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServiceByID", reflect.TypeOf((*MockStore)(nil).GetServiceByID), ctx, lockStrength, accountID, serviceID) +} + +// GetServiceTargetByTargetID mocks base method. +func (m *MockStore) GetServiceTargetByTargetID(ctx context.Context, lockStrength LockingStrength, accountID, targetID string) (*service.Target, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetServiceTargetByTargetID", ctx, lockStrength, accountID, targetID) + ret0, _ := ret[0].(*service.Target) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetServiceTargetByTargetID indicates an expected call of GetServiceTargetByTargetID. +func (mr *MockStoreMockRecorder) GetServiceTargetByTargetID(ctx, lockStrength, accountID, targetID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServiceTargetByTargetID", reflect.TypeOf((*MockStore)(nil).GetServiceTargetByTargetID), ctx, lockStrength, accountID, targetID) +} + +// GetServices mocks base method. +func (m *MockStore) GetServices(ctx context.Context, lockStrength LockingStrength) ([]*service.Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetServices", ctx, lockStrength) + ret0, _ := ret[0].([]*service.Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetServices indicates an expected call of GetServices. +func (mr *MockStoreMockRecorder) GetServices(ctx, lockStrength interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServices", reflect.TypeOf((*MockStore)(nil).GetServices), ctx, lockStrength) +} + +// GetServicesByCluster mocks base method. +func (m *MockStore) GetServicesByCluster(ctx context.Context, lockStrength LockingStrength, proxyCluster string) ([]*service.Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetServicesByCluster", ctx, lockStrength, proxyCluster) + ret0, _ := ret[0].([]*service.Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetServicesByCluster indicates an expected call of GetServicesByCluster. +func (mr *MockStoreMockRecorder) GetServicesByCluster(ctx, lockStrength, proxyCluster interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServicesByCluster", reflect.TypeOf((*MockStore)(nil).GetServicesByCluster), ctx, lockStrength, proxyCluster) +} + +// GetServicesByClusterAndPort mocks base method. +func (m *MockStore) GetServicesByClusterAndPort(ctx context.Context, lockStrength LockingStrength, proxyCluster, mode string, listenPort uint16) ([]*service.Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetServicesByClusterAndPort", ctx, lockStrength, proxyCluster, mode, listenPort) + ret0, _ := ret[0].([]*service.Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetServicesByClusterAndPort indicates an expected call of GetServicesByClusterAndPort. +func (mr *MockStoreMockRecorder) GetServicesByClusterAndPort(ctx, lockStrength, proxyCluster, mode, listenPort interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServicesByClusterAndPort", reflect.TypeOf((*MockStore)(nil).GetServicesByClusterAndPort), ctx, lockStrength, proxyCluster, mode, listenPort) +} + +// GetSetupKeyByID mocks base method. +func (m *MockStore) GetSetupKeyByID(ctx context.Context, lockStrength LockingStrength, accountID, setupKeyID string) (*types2.SetupKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSetupKeyByID", ctx, lockStrength, accountID, setupKeyID) + ret0, _ := ret[0].(*types2.SetupKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetSetupKeyByID indicates an expected call of GetSetupKeyByID. +func (mr *MockStoreMockRecorder) GetSetupKeyByID(ctx, lockStrength, accountID, setupKeyID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSetupKeyByID", reflect.TypeOf((*MockStore)(nil).GetSetupKeyByID), ctx, lockStrength, accountID, setupKeyID) +} + +// GetSetupKeyBySecret mocks base method. +func (m *MockStore) GetSetupKeyBySecret(ctx context.Context, lockStrength LockingStrength, key string) (*types2.SetupKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSetupKeyBySecret", ctx, lockStrength, key) + ret0, _ := ret[0].(*types2.SetupKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetSetupKeyBySecret indicates an expected call of GetSetupKeyBySecret. +func (mr *MockStoreMockRecorder) GetSetupKeyBySecret(ctx, lockStrength, key interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSetupKeyBySecret", reflect.TypeOf((*MockStore)(nil).GetSetupKeyBySecret), ctx, lockStrength, key) +} + +// GetStoreEngine mocks base method. +func (m *MockStore) GetStoreEngine() types2.Engine { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetStoreEngine") + ret0, _ := ret[0].(types2.Engine) + return ret0 +} + +// GetStoreEngine indicates an expected call of GetStoreEngine. +func (mr *MockStoreMockRecorder) GetStoreEngine() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetStoreEngine", reflect.TypeOf((*MockStore)(nil).GetStoreEngine)) +} + +// GetTakenIPs mocks base method. +func (m *MockStore) GetTakenIPs(ctx context.Context, lockStrength LockingStrength, accountId string) ([]net.IP, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTakenIPs", ctx, lockStrength, accountId) + ret0, _ := ret[0].([]net.IP) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTakenIPs indicates an expected call of GetTakenIPs. +func (mr *MockStoreMockRecorder) GetTakenIPs(ctx, lockStrength, accountId interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTakenIPs", reflect.TypeOf((*MockStore)(nil).GetTakenIPs), ctx, lockStrength, accountId) +} + +// GetTargetsByServiceID mocks base method. +func (m *MockStore) GetTargetsByServiceID(ctx context.Context, lockStrength LockingStrength, accountID, serviceID string) ([]*service.Target, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTargetsByServiceID", ctx, lockStrength, accountID, serviceID) + ret0, _ := ret[0].([]*service.Target) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTargetsByServiceID indicates an expected call of GetTargetsByServiceID. +func (mr *MockStoreMockRecorder) GetTargetsByServiceID(ctx, lockStrength, accountID, serviceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTargetsByServiceID", reflect.TypeOf((*MockStore)(nil).GetTargetsByServiceID), ctx, lockStrength, accountID, serviceID) +} + +// GetTokenIDByHashedToken mocks base method. +func (m *MockStore) GetTokenIDByHashedToken(ctx context.Context, secret string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTokenIDByHashedToken", ctx, secret) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTokenIDByHashedToken indicates an expected call of GetTokenIDByHashedToken. +func (mr *MockStoreMockRecorder) GetTokenIDByHashedToken(ctx, secret interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTokenIDByHashedToken", reflect.TypeOf((*MockStore)(nil).GetTokenIDByHashedToken), ctx, secret) +} + +// GetUserByPATID mocks base method. +func (m *MockStore) GetUserByPATID(ctx context.Context, lockStrength LockingStrength, patID string) (*types2.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserByPATID", ctx, lockStrength, patID) + ret0, _ := ret[0].(*types2.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserByPATID indicates an expected call of GetUserByPATID. +func (mr *MockStoreMockRecorder) GetUserByPATID(ctx, lockStrength, patID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByPATID", reflect.TypeOf((*MockStore)(nil).GetUserByPATID), ctx, lockStrength, patID) +} + +// GetUserByUserID mocks base method. +func (m *MockStore) GetUserByUserID(ctx context.Context, lockStrength LockingStrength, userID string) (*types2.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserByUserID", ctx, lockStrength, userID) + ret0, _ := ret[0].(*types2.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserByUserID indicates an expected call of GetUserByUserID. +func (mr *MockStoreMockRecorder) GetUserByUserID(ctx, lockStrength, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByUserID", reflect.TypeOf((*MockStore)(nil).GetUserByUserID), ctx, lockStrength, userID) +} + +// GetUserIDByPeerKey mocks base method. +func (m *MockStore) GetUserIDByPeerKey(ctx context.Context, lockStrength LockingStrength, peerKey string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserIDByPeerKey", ctx, lockStrength, peerKey) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserIDByPeerKey indicates an expected call of GetUserIDByPeerKey. +func (mr *MockStoreMockRecorder) GetUserIDByPeerKey(ctx, lockStrength, peerKey interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserIDByPeerKey", reflect.TypeOf((*MockStore)(nil).GetUserIDByPeerKey), ctx, lockStrength, peerKey) +} + +// GetUserInviteByEmail mocks base method. +func (m *MockStore) GetUserInviteByEmail(ctx context.Context, lockStrength LockingStrength, accountID, email string) (*types2.UserInviteRecord, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserInviteByEmail", ctx, lockStrength, accountID, email) + ret0, _ := ret[0].(*types2.UserInviteRecord) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserInviteByEmail indicates an expected call of GetUserInviteByEmail. +func (mr *MockStoreMockRecorder) GetUserInviteByEmail(ctx, lockStrength, accountID, email interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserInviteByEmail", reflect.TypeOf((*MockStore)(nil).GetUserInviteByEmail), ctx, lockStrength, accountID, email) +} + +// GetUserInviteByHashedToken mocks base method. +func (m *MockStore) GetUserInviteByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken string) (*types2.UserInviteRecord, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserInviteByHashedToken", ctx, lockStrength, hashedToken) + ret0, _ := ret[0].(*types2.UserInviteRecord) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserInviteByHashedToken indicates an expected call of GetUserInviteByHashedToken. +func (mr *MockStoreMockRecorder) GetUserInviteByHashedToken(ctx, lockStrength, hashedToken interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserInviteByHashedToken", reflect.TypeOf((*MockStore)(nil).GetUserInviteByHashedToken), ctx, lockStrength, hashedToken) +} + +// GetUserInviteByID mocks base method. +func (m *MockStore) GetUserInviteByID(ctx context.Context, lockStrength LockingStrength, accountID, inviteID string) (*types2.UserInviteRecord, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserInviteByID", ctx, lockStrength, accountID, inviteID) + ret0, _ := ret[0].(*types2.UserInviteRecord) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserInviteByID indicates an expected call of GetUserInviteByID. +func (mr *MockStoreMockRecorder) GetUserInviteByID(ctx, lockStrength, accountID, inviteID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserInviteByID", reflect.TypeOf((*MockStore)(nil).GetUserInviteByID), ctx, lockStrength, accountID, inviteID) +} + +// GetUserPATs mocks base method. +func (m *MockStore) GetUserPATs(ctx context.Context, lockStrength LockingStrength, userID string) ([]*types2.PersonalAccessToken, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserPATs", ctx, lockStrength, userID) + ret0, _ := ret[0].([]*types2.PersonalAccessToken) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserPATs indicates an expected call of GetUserPATs. +func (mr *MockStoreMockRecorder) GetUserPATs(ctx, lockStrength, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserPATs", reflect.TypeOf((*MockStore)(nil).GetUserPATs), ctx, lockStrength, userID) +} + +// GetUserPeers mocks base method. +func (m *MockStore) GetUserPeers(ctx context.Context, lockStrength LockingStrength, accountID, userID string) ([]*peer.Peer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserPeers", ctx, lockStrength, accountID, userID) + ret0, _ := ret[0].([]*peer.Peer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserPeers indicates an expected call of GetUserPeers. +func (mr *MockStoreMockRecorder) GetUserPeers(ctx, lockStrength, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserPeers", reflect.TypeOf((*MockStore)(nil).GetUserPeers), ctx, lockStrength, accountID, userID) +} + +// GetZoneByDomain mocks base method. +func (m *MockStore) GetZoneByDomain(ctx context.Context, accountID, domain string) (*zones.Zone, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetZoneByDomain", ctx, accountID, domain) + ret0, _ := ret[0].(*zones.Zone) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetZoneByDomain indicates an expected call of GetZoneByDomain. +func (mr *MockStoreMockRecorder) GetZoneByDomain(ctx, accountID, domain interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetZoneByDomain", reflect.TypeOf((*MockStore)(nil).GetZoneByDomain), ctx, accountID, domain) +} + +// GetZoneByID mocks base method. +func (m *MockStore) GetZoneByID(ctx context.Context, lockStrength LockingStrength, accountID, zoneID string) (*zones.Zone, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetZoneByID", ctx, lockStrength, accountID, zoneID) + ret0, _ := ret[0].(*zones.Zone) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetZoneByID indicates an expected call of GetZoneByID. +func (mr *MockStoreMockRecorder) GetZoneByID(ctx, lockStrength, accountID, zoneID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetZoneByID", reflect.TypeOf((*MockStore)(nil).GetZoneByID), ctx, lockStrength, accountID, zoneID) +} + +// GetZoneDNSRecords mocks base method. +func (m *MockStore) GetZoneDNSRecords(ctx context.Context, lockStrength LockingStrength, accountID, zoneID string) ([]*records.Record, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetZoneDNSRecords", ctx, lockStrength, accountID, zoneID) + ret0, _ := ret[0].([]*records.Record) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetZoneDNSRecords indicates an expected call of GetZoneDNSRecords. +func (mr *MockStoreMockRecorder) GetZoneDNSRecords(ctx, lockStrength, accountID, zoneID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetZoneDNSRecords", reflect.TypeOf((*MockStore)(nil).GetZoneDNSRecords), ctx, lockStrength, accountID, zoneID) +} + +// GetZoneDNSRecordsByName mocks base method. +func (m *MockStore) GetZoneDNSRecordsByName(ctx context.Context, lockStrength LockingStrength, accountID, zoneID, name string) ([]*records.Record, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetZoneDNSRecordsByName", ctx, lockStrength, accountID, zoneID, name) + ret0, _ := ret[0].([]*records.Record) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetZoneDNSRecordsByName indicates an expected call of GetZoneDNSRecordsByName. +func (mr *MockStoreMockRecorder) GetZoneDNSRecordsByName(ctx, lockStrength, accountID, zoneID, name interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetZoneDNSRecordsByName", reflect.TypeOf((*MockStore)(nil).GetZoneDNSRecordsByName), ctx, lockStrength, accountID, zoneID, name) +} + +// IncrementNetworkSerial mocks base method. +func (m *MockStore) IncrementNetworkSerial(ctx context.Context, accountId string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IncrementNetworkSerial", ctx, accountId) + ret0, _ := ret[0].(error) + return ret0 +} + +// IncrementNetworkSerial indicates an expected call of IncrementNetworkSerial. +func (mr *MockStoreMockRecorder) IncrementNetworkSerial(ctx, accountId interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementNetworkSerial", reflect.TypeOf((*MockStore)(nil).IncrementNetworkSerial), ctx, accountId) +} + +// IncrementSetupKeyUsage mocks base method. +func (m *MockStore) IncrementSetupKeyUsage(ctx context.Context, setupKeyID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IncrementSetupKeyUsage", ctx, setupKeyID) + ret0, _ := ret[0].(error) + return ret0 +} + +// IncrementSetupKeyUsage indicates an expected call of IncrementSetupKeyUsage. +func (mr *MockStoreMockRecorder) IncrementSetupKeyUsage(ctx, setupKeyID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementSetupKeyUsage", reflect.TypeOf((*MockStore)(nil).IncrementSetupKeyUsage), ctx, setupKeyID) +} + +// IsPrimaryAccount mocks base method. +func (m *MockStore) IsPrimaryAccount(ctx context.Context, accountID string) (bool, string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsPrimaryAccount", ctx, accountID) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(string) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// IsPrimaryAccount indicates an expected call of IsPrimaryAccount. +func (mr *MockStoreMockRecorder) IsPrimaryAccount(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsPrimaryAccount", reflect.TypeOf((*MockStore)(nil).IsPrimaryAccount), ctx, accountID) +} + +// ListCustomDomains mocks base method. +func (m *MockStore) ListCustomDomains(ctx context.Context, accountID string) ([]*domain.Domain, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListCustomDomains", ctx, accountID) + ret0, _ := ret[0].([]*domain.Domain) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListCustomDomains indicates an expected call of ListCustomDomains. +func (mr *MockStoreMockRecorder) ListCustomDomains(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListCustomDomains", reflect.TypeOf((*MockStore)(nil).ListCustomDomains), ctx, accountID) +} + +// ListFreeDomains mocks base method. +func (m *MockStore) ListFreeDomains(ctx context.Context, accountID string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListFreeDomains", ctx, accountID) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListFreeDomains indicates an expected call of ListFreeDomains. +func (mr *MockStoreMockRecorder) ListFreeDomains(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListFreeDomains", reflect.TypeOf((*MockStore)(nil).ListFreeDomains), ctx, accountID) +} + +// MarkAccountPrimary mocks base method. +func (m *MockStore) MarkAccountPrimary(ctx context.Context, accountID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MarkAccountPrimary", ctx, accountID) + ret0, _ := ret[0].(error) + return ret0 +} + +// MarkAccountPrimary indicates an expected call of MarkAccountPrimary. +func (mr *MockStoreMockRecorder) MarkAccountPrimary(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkAccountPrimary", reflect.TypeOf((*MockStore)(nil).MarkAccountPrimary), ctx, accountID) +} + +// MarkAllPendingJobsAsFailed mocks base method. +func (m *MockStore) MarkAllPendingJobsAsFailed(ctx context.Context, accountID, peerID, reason string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MarkAllPendingJobsAsFailed", ctx, accountID, peerID, reason) + ret0, _ := ret[0].(error) + return ret0 +} + +// MarkAllPendingJobsAsFailed indicates an expected call of MarkAllPendingJobsAsFailed. +func (mr *MockStoreMockRecorder) MarkAllPendingJobsAsFailed(ctx, accountID, peerID, reason interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkAllPendingJobsAsFailed", reflect.TypeOf((*MockStore)(nil).MarkAllPendingJobsAsFailed), ctx, accountID, peerID, reason) +} + +// MarkPATUsed mocks base method. +func (m *MockStore) MarkPATUsed(ctx context.Context, patID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MarkPATUsed", ctx, patID) + ret0, _ := ret[0].(error) + return ret0 +} + +// MarkPATUsed indicates an expected call of MarkPATUsed. +func (mr *MockStoreMockRecorder) MarkPATUsed(ctx, patID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPATUsed", reflect.TypeOf((*MockStore)(nil).MarkPATUsed), ctx, patID) +} + +// MarkPendingJobsAsFailed mocks base method. +func (m *MockStore) MarkPendingJobsAsFailed(ctx context.Context, accountID, peerID, jobID, reason string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MarkPendingJobsAsFailed", ctx, accountID, peerID, jobID, reason) + ret0, _ := ret[0].(error) + return ret0 +} + +// MarkPendingJobsAsFailed indicates an expected call of MarkPendingJobsAsFailed. +func (mr *MockStoreMockRecorder) MarkPendingJobsAsFailed(ctx, accountID, peerID, jobID, reason interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPendingJobsAsFailed", reflect.TypeOf((*MockStore)(nil).MarkPendingJobsAsFailed), ctx, accountID, peerID, jobID, reason) +} + +// MarkProxyAccessTokenUsed mocks base method. +func (m *MockStore) MarkProxyAccessTokenUsed(ctx context.Context, tokenID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MarkProxyAccessTokenUsed", ctx, tokenID) + ret0, _ := ret[0].(error) + return ret0 +} + +// MarkProxyAccessTokenUsed indicates an expected call of MarkProxyAccessTokenUsed. +func (mr *MockStoreMockRecorder) MarkProxyAccessTokenUsed(ctx, tokenID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkProxyAccessTokenUsed", reflect.TypeOf((*MockStore)(nil).MarkProxyAccessTokenUsed), ctx, tokenID) +} + +// RemovePeerFromAllGroups mocks base method. +func (m *MockStore) RemovePeerFromAllGroups(ctx context.Context, peerID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemovePeerFromAllGroups", ctx, peerID) + ret0, _ := ret[0].(error) + return ret0 +} + +// RemovePeerFromAllGroups indicates an expected call of RemovePeerFromAllGroups. +func (mr *MockStoreMockRecorder) RemovePeerFromAllGroups(ctx, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemovePeerFromAllGroups", reflect.TypeOf((*MockStore)(nil).RemovePeerFromAllGroups), ctx, peerID) +} + +// RemovePeerFromGroup mocks base method. +func (m *MockStore) RemovePeerFromGroup(ctx context.Context, peerID, groupID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemovePeerFromGroup", ctx, peerID, groupID) + ret0, _ := ret[0].(error) + return ret0 +} + +// RemovePeerFromGroup indicates an expected call of RemovePeerFromGroup. +func (mr *MockStoreMockRecorder) RemovePeerFromGroup(ctx, peerID, groupID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemovePeerFromGroup", reflect.TypeOf((*MockStore)(nil).RemovePeerFromGroup), ctx, peerID, groupID) +} + +// RemoveResourceFromGroup mocks base method. +func (m *MockStore) RemoveResourceFromGroup(ctx context.Context, accountId, groupID, resourceID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemoveResourceFromGroup", ctx, accountId, groupID, resourceID) + ret0, _ := ret[0].(error) + return ret0 +} + +// RemoveResourceFromGroup indicates an expected call of RemoveResourceFromGroup. +func (mr *MockStoreMockRecorder) RemoveResourceFromGroup(ctx, accountId, groupID, resourceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveResourceFromGroup", reflect.TypeOf((*MockStore)(nil).RemoveResourceFromGroup), ctx, accountId, groupID, resourceID) +} + +// RenewEphemeralService mocks base method. +func (m *MockStore) RenewEphemeralService(ctx context.Context, accountID, peerID, serviceID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RenewEphemeralService", ctx, accountID, peerID, serviceID) + ret0, _ := ret[0].(error) + return ret0 +} + +// RenewEphemeralService indicates an expected call of RenewEphemeralService. +func (mr *MockStoreMockRecorder) RenewEphemeralService(ctx, accountID, peerID, serviceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenewEphemeralService", reflect.TypeOf((*MockStore)(nil).RenewEphemeralService), ctx, accountID, peerID, serviceID) +} + +// RevokeProxyAccessToken mocks base method. +func (m *MockStore) RevokeProxyAccessToken(ctx context.Context, tokenID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RevokeProxyAccessToken", ctx, tokenID) + ret0, _ := ret[0].(error) + return ret0 +} + +// RevokeProxyAccessToken indicates an expected call of RevokeProxyAccessToken. +func (mr *MockStoreMockRecorder) RevokeProxyAccessToken(ctx, tokenID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RevokeProxyAccessToken", reflect.TypeOf((*MockStore)(nil).RevokeProxyAccessToken), ctx, tokenID) +} + +// SaveAccount mocks base method. +func (m *MockStore) SaveAccount(ctx context.Context, account *types2.Account) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveAccount", ctx, account) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveAccount indicates an expected call of SaveAccount. +func (mr *MockStoreMockRecorder) SaveAccount(ctx, account interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAccount", reflect.TypeOf((*MockStore)(nil).SaveAccount), ctx, account) +} + +// SaveAccountOnboarding mocks base method. +func (m *MockStore) SaveAccountOnboarding(ctx context.Context, onboarding *types2.AccountOnboarding) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveAccountOnboarding", ctx, onboarding) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveAccountOnboarding indicates an expected call of SaveAccountOnboarding. +func (mr *MockStoreMockRecorder) SaveAccountOnboarding(ctx, onboarding interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAccountOnboarding", reflect.TypeOf((*MockStore)(nil).SaveAccountOnboarding), ctx, onboarding) +} + +// SaveAccountSettings mocks base method. +func (m *MockStore) SaveAccountSettings(ctx context.Context, accountID string, settings *types2.Settings) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveAccountSettings", ctx, accountID, settings) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveAccountSettings indicates an expected call of SaveAccountSettings. +func (mr *MockStoreMockRecorder) SaveAccountSettings(ctx, accountID, settings interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAccountSettings", reflect.TypeOf((*MockStore)(nil).SaveAccountSettings), ctx, accountID, settings) +} + +// SaveDNSSettings mocks base method. +func (m *MockStore) SaveDNSSettings(ctx context.Context, accountID string, settings *types2.DNSSettings) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveDNSSettings", ctx, accountID, settings) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveDNSSettings indicates an expected call of SaveDNSSettings. +func (mr *MockStoreMockRecorder) SaveDNSSettings(ctx, accountID, settings interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveDNSSettings", reflect.TypeOf((*MockStore)(nil).SaveDNSSettings), ctx, accountID, settings) +} + +// SaveInstallationID mocks base method. +func (m *MockStore) SaveInstallationID(ctx context.Context, ID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveInstallationID", ctx, ID) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveInstallationID indicates an expected call of SaveInstallationID. +func (mr *MockStoreMockRecorder) SaveInstallationID(ctx, ID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveInstallationID", reflect.TypeOf((*MockStore)(nil).SaveInstallationID), ctx, ID) +} + +// SaveNameServerGroup mocks base method. +func (m *MockStore) SaveNameServerGroup(ctx context.Context, nameServerGroup *dns.NameServerGroup) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveNameServerGroup", ctx, nameServerGroup) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveNameServerGroup indicates an expected call of SaveNameServerGroup. +func (mr *MockStoreMockRecorder) SaveNameServerGroup(ctx, nameServerGroup interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveNameServerGroup", reflect.TypeOf((*MockStore)(nil).SaveNameServerGroup), ctx, nameServerGroup) +} + +// SaveNetwork mocks base method. +func (m *MockStore) SaveNetwork(ctx context.Context, network *types1.Network) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveNetwork", ctx, network) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveNetwork indicates an expected call of SaveNetwork. +func (mr *MockStoreMockRecorder) SaveNetwork(ctx, network interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveNetwork", reflect.TypeOf((*MockStore)(nil).SaveNetwork), ctx, network) +} + +// SaveNetworkResource mocks base method. +func (m *MockStore) SaveNetworkResource(ctx context.Context, resource *types.NetworkResource) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveNetworkResource", ctx, resource) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveNetworkResource indicates an expected call of SaveNetworkResource. +func (mr *MockStoreMockRecorder) SaveNetworkResource(ctx, resource interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveNetworkResource", reflect.TypeOf((*MockStore)(nil).SaveNetworkResource), ctx, resource) +} + +// SaveNetworkRouter mocks base method. +func (m *MockStore) SaveNetworkRouter(ctx context.Context, router *types0.NetworkRouter) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveNetworkRouter", ctx, router) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveNetworkRouter indicates an expected call of SaveNetworkRouter. +func (mr *MockStoreMockRecorder) SaveNetworkRouter(ctx, router interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveNetworkRouter", reflect.TypeOf((*MockStore)(nil).SaveNetworkRouter), ctx, router) +} + +// SavePAT mocks base method. +func (m *MockStore) SavePAT(ctx context.Context, pat *types2.PersonalAccessToken) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SavePAT", ctx, pat) + ret0, _ := ret[0].(error) + return ret0 +} + +// SavePAT indicates an expected call of SavePAT. +func (mr *MockStoreMockRecorder) SavePAT(ctx, pat interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePAT", reflect.TypeOf((*MockStore)(nil).SavePAT), ctx, pat) +} + +// SavePeer mocks base method. +func (m *MockStore) SavePeer(ctx context.Context, accountID string, peer *peer.Peer) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SavePeer", ctx, accountID, peer) + ret0, _ := ret[0].(error) + return ret0 +} + +// SavePeer indicates an expected call of SavePeer. +func (mr *MockStoreMockRecorder) SavePeer(ctx, accountID, peer interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePeer", reflect.TypeOf((*MockStore)(nil).SavePeer), ctx, accountID, peer) +} + +// SavePeerLocation mocks base method. +func (m *MockStore) SavePeerLocation(ctx context.Context, accountID string, peer *peer.Peer) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SavePeerLocation", ctx, accountID, peer) + ret0, _ := ret[0].(error) + return ret0 +} + +// SavePeerLocation indicates an expected call of SavePeerLocation. +func (mr *MockStoreMockRecorder) SavePeerLocation(ctx, accountID, peer interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePeerLocation", reflect.TypeOf((*MockStore)(nil).SavePeerLocation), ctx, accountID, peer) +} + +// SavePeerStatus mocks base method. +func (m *MockStore) SavePeerStatus(ctx context.Context, accountID, peerID string, status peer.PeerStatus) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SavePeerStatus", ctx, accountID, peerID, status) + ret0, _ := ret[0].(error) + return ret0 +} + +// SavePeerStatus indicates an expected call of SavePeerStatus. +func (mr *MockStoreMockRecorder) SavePeerStatus(ctx, accountID, peerID, status interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePeerStatus", reflect.TypeOf((*MockStore)(nil).SavePeerStatus), ctx, accountID, peerID, status) +} + +// SavePolicy mocks base method. +func (m *MockStore) SavePolicy(ctx context.Context, policy *types2.Policy) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SavePolicy", ctx, policy) + ret0, _ := ret[0].(error) + return ret0 +} + +// SavePolicy indicates an expected call of SavePolicy. +func (mr *MockStoreMockRecorder) SavePolicy(ctx, policy interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePolicy", reflect.TypeOf((*MockStore)(nil).SavePolicy), ctx, policy) +} + +// SavePostureChecks mocks base method. +func (m *MockStore) SavePostureChecks(ctx context.Context, postureCheck *posture.Checks) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SavePostureChecks", ctx, postureCheck) + ret0, _ := ret[0].(error) + return ret0 +} + +// SavePostureChecks indicates an expected call of SavePostureChecks. +func (mr *MockStoreMockRecorder) SavePostureChecks(ctx, postureCheck interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePostureChecks", reflect.TypeOf((*MockStore)(nil).SavePostureChecks), ctx, postureCheck) +} + +// SaveProxy mocks base method. +func (m *MockStore) SaveProxy(ctx context.Context, proxy *proxy.Proxy) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveProxy", ctx, proxy) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveProxy indicates an expected call of SaveProxy. +func (mr *MockStoreMockRecorder) SaveProxy(ctx, proxy interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveProxy", reflect.TypeOf((*MockStore)(nil).SaveProxy), ctx, proxy) +} + +// SaveProxyAccessToken mocks base method. +func (m *MockStore) SaveProxyAccessToken(ctx context.Context, token *types2.ProxyAccessToken) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveProxyAccessToken", ctx, token) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveProxyAccessToken indicates an expected call of SaveProxyAccessToken. +func (mr *MockStoreMockRecorder) SaveProxyAccessToken(ctx, token interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveProxyAccessToken", reflect.TypeOf((*MockStore)(nil).SaveProxyAccessToken), ctx, token) +} + +// SaveRoute mocks base method. +func (m *MockStore) SaveRoute(ctx context.Context, route *route.Route) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveRoute", ctx, route) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveRoute indicates an expected call of SaveRoute. +func (mr *MockStoreMockRecorder) SaveRoute(ctx, route interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveRoute", reflect.TypeOf((*MockStore)(nil).SaveRoute), ctx, route) +} + +// SaveSetupKey mocks base method. +func (m *MockStore) SaveSetupKey(ctx context.Context, setupKey *types2.SetupKey) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveSetupKey", ctx, setupKey) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveSetupKey indicates an expected call of SaveSetupKey. +func (mr *MockStoreMockRecorder) SaveSetupKey(ctx, setupKey interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveSetupKey", reflect.TypeOf((*MockStore)(nil).SaveSetupKey), ctx, setupKey) +} + +// SaveUser mocks base method. +func (m *MockStore) SaveUser(ctx context.Context, user *types2.User) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveUser", ctx, user) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveUser indicates an expected call of SaveUser. +func (mr *MockStoreMockRecorder) SaveUser(ctx, user interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveUser", reflect.TypeOf((*MockStore)(nil).SaveUser), ctx, user) +} + +// SaveUserInvite mocks base method. +func (m *MockStore) SaveUserInvite(ctx context.Context, invite *types2.UserInviteRecord) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveUserInvite", ctx, invite) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveUserInvite indicates an expected call of SaveUserInvite. +func (mr *MockStoreMockRecorder) SaveUserInvite(ctx, invite interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveUserInvite", reflect.TypeOf((*MockStore)(nil).SaveUserInvite), ctx, invite) +} + +// SaveUserLastLogin mocks base method. +func (m *MockStore) SaveUserLastLogin(ctx context.Context, accountID, userID string, lastLogin time.Time) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveUserLastLogin", ctx, accountID, userID, lastLogin) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveUserLastLogin indicates an expected call of SaveUserLastLogin. +func (mr *MockStoreMockRecorder) SaveUserLastLogin(ctx, accountID, userID, lastLogin interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveUserLastLogin", reflect.TypeOf((*MockStore)(nil).SaveUserLastLogin), ctx, accountID, userID, lastLogin) +} + +// SaveUsers mocks base method. +func (m *MockStore) SaveUsers(ctx context.Context, users []*types2.User) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveUsers", ctx, users) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveUsers indicates an expected call of SaveUsers. +func (mr *MockStoreMockRecorder) SaveUsers(ctx, users interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveUsers", reflect.TypeOf((*MockStore)(nil).SaveUsers), ctx, users) +} + +// SetFieldEncrypt mocks base method. +func (m *MockStore) SetFieldEncrypt(enc *crypt.FieldEncrypt) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetFieldEncrypt", enc) +} + +// SetFieldEncrypt indicates an expected call of SetFieldEncrypt. +func (mr *MockStoreMockRecorder) SetFieldEncrypt(enc interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetFieldEncrypt", reflect.TypeOf((*MockStore)(nil).SetFieldEncrypt), enc) +} + +// UpdateAccountDomainAttributes mocks base method. +func (m *MockStore) UpdateAccountDomainAttributes(ctx context.Context, accountID, domain, category string, isPrimaryDomain bool) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateAccountDomainAttributes", ctx, accountID, domain, category, isPrimaryDomain) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateAccountDomainAttributes indicates an expected call of UpdateAccountDomainAttributes. +func (mr *MockStoreMockRecorder) UpdateAccountDomainAttributes(ctx, accountID, domain, category, isPrimaryDomain interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountDomainAttributes", reflect.TypeOf((*MockStore)(nil).UpdateAccountDomainAttributes), ctx, accountID, domain, category, isPrimaryDomain) +} + +// UpdateAccountNetwork mocks base method. +func (m *MockStore) UpdateAccountNetwork(ctx context.Context, accountID string, ipNet net.IPNet) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateAccountNetwork", ctx, accountID, ipNet) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateAccountNetwork indicates an expected call of UpdateAccountNetwork. +func (mr *MockStoreMockRecorder) UpdateAccountNetwork(ctx, accountID, ipNet interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountNetwork", reflect.TypeOf((*MockStore)(nil).UpdateAccountNetwork), ctx, accountID, ipNet) +} + +// UpdateCustomDomain mocks base method. +func (m *MockStore) UpdateCustomDomain(ctx context.Context, accountID string, d *domain.Domain) (*domain.Domain, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateCustomDomain", ctx, accountID, d) + ret0, _ := ret[0].(*domain.Domain) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateCustomDomain indicates an expected call of UpdateCustomDomain. +func (mr *MockStoreMockRecorder) UpdateCustomDomain(ctx, accountID, d interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateCustomDomain", reflect.TypeOf((*MockStore)(nil).UpdateCustomDomain), ctx, accountID, d) +} + +// UpdateDNSRecord mocks base method. +func (m *MockStore) UpdateDNSRecord(ctx context.Context, record *records.Record) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateDNSRecord", ctx, record) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateDNSRecord indicates an expected call of UpdateDNSRecord. +func (mr *MockStoreMockRecorder) UpdateDNSRecord(ctx, record interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateDNSRecord", reflect.TypeOf((*MockStore)(nil).UpdateDNSRecord), ctx, record) +} + +// UpdateGroup mocks base method. +func (m *MockStore) UpdateGroup(ctx context.Context, group *types2.Group) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateGroup", ctx, group) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateGroup indicates an expected call of UpdateGroup. +func (mr *MockStoreMockRecorder) UpdateGroup(ctx, group interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateGroup", reflect.TypeOf((*MockStore)(nil).UpdateGroup), ctx, group) +} + +// UpdateGroups mocks base method. +func (m *MockStore) UpdateGroups(ctx context.Context, accountID string, groups []*types2.Group) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateGroups", ctx, accountID, groups) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateGroups indicates an expected call of UpdateGroups. +func (mr *MockStoreMockRecorder) UpdateGroups(ctx, accountID, groups interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateGroups", reflect.TypeOf((*MockStore)(nil).UpdateGroups), ctx, accountID, groups) +} + +// UpdateProxyHeartbeat mocks base method. +func (m *MockStore) UpdateProxyHeartbeat(ctx context.Context, proxyID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateProxyHeartbeat", ctx, proxyID) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateProxyHeartbeat indicates an expected call of UpdateProxyHeartbeat. +func (mr *MockStoreMockRecorder) UpdateProxyHeartbeat(ctx, proxyID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateProxyHeartbeat", reflect.TypeOf((*MockStore)(nil).UpdateProxyHeartbeat), ctx, proxyID) +} + +// UpdateService mocks base method. +func (m *MockStore) UpdateService(ctx context.Context, service *service.Service) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateService", ctx, service) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateService indicates an expected call of UpdateService. +func (mr *MockStoreMockRecorder) UpdateService(ctx, service interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateService", reflect.TypeOf((*MockStore)(nil).UpdateService), ctx, service) +} + +// UpdateZone mocks base method. +func (m *MockStore) UpdateZone(ctx context.Context, zone *zones.Zone) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateZone", ctx, zone) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateZone indicates an expected call of UpdateZone. +func (mr *MockStoreMockRecorder) UpdateZone(ctx, zone interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateZone", reflect.TypeOf((*MockStore)(nil).UpdateZone), ctx, zone) +} diff --git a/management/server/telemetry/account_aggregator.go b/management/server/telemetry/account_aggregator.go new file mode 100644 index 000000000..cd0863ed6 --- /dev/null +++ b/management/server/telemetry/account_aggregator.go @@ -0,0 +1,185 @@ +package telemetry + +import ( + "context" + "math" + "sync" + "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" +) + +// AccountDurationAggregator uses OpenTelemetry histograms per account to calculate P95 +// without publishing individual account labels +type AccountDurationAggregator struct { + mu sync.RWMutex + accounts map[string]*accountHistogram + meterProvider *sdkmetric.MeterProvider + manualReader *sdkmetric.ManualReader + + FlushInterval time.Duration + MaxAge time.Duration + ctx context.Context +} + +type accountHistogram struct { + histogram metric.Int64Histogram + lastUpdate time.Time +} + +// NewAccountDurationAggregator creates aggregator using OTel histograms +func NewAccountDurationAggregator(ctx context.Context, flushInterval, maxAge time.Duration) *AccountDurationAggregator { + manualReader := sdkmetric.NewManualReader( + sdkmetric.WithTemporalitySelector(func(kind sdkmetric.InstrumentKind) metricdata.Temporality { + return metricdata.DeltaTemporality + }), + ) + + meterProvider := sdkmetric.NewMeterProvider( + sdkmetric.WithReader(manualReader), + ) + + return &AccountDurationAggregator{ + accounts: make(map[string]*accountHistogram), + meterProvider: meterProvider, + manualReader: manualReader, + FlushInterval: flushInterval, + MaxAge: maxAge, + ctx: ctx, + } +} + +// Record adds a duration for an account using OTel histogram +func (a *AccountDurationAggregator) Record(accountID string, duration time.Duration) { + a.mu.Lock() + defer a.mu.Unlock() + + accHist, exists := a.accounts[accountID] + if !exists { + meter := a.meterProvider.Meter("account-aggregator") + histogram, err := meter.Int64Histogram( + "sync_duration_per_account", + metric.WithUnit("milliseconds"), + ) + if err != nil { + return + } + + accHist = &accountHistogram{ + histogram: histogram, + } + a.accounts[accountID] = accHist + } + + accHist.histogram.Record(a.ctx, duration.Milliseconds(), + metric.WithAttributes(attribute.String("account_id", accountID))) + accHist.lastUpdate = time.Now() +} + +// FlushAndGetP95s extracts P95 from each account's histogram +func (a *AccountDurationAggregator) FlushAndGetP95s() []int64 { + a.mu.Lock() + defer a.mu.Unlock() + + var rm metricdata.ResourceMetrics + err := a.manualReader.Collect(a.ctx, &rm) + if err != nil { + return nil + } + + now := time.Now() + p95s := make([]int64, 0, len(a.accounts)) + + for _, scopeMetrics := range rm.ScopeMetrics { + for _, metric := range scopeMetrics.Metrics { + histogramData, ok := metric.Data.(metricdata.Histogram[int64]) + if !ok { + continue + } + + for _, dataPoint := range histogramData.DataPoints { + a.processDataPoint(dataPoint, now, &p95s) + } + } + } + + a.cleanupStaleAccounts(now) + + return p95s +} + +// processDataPoint extracts P95 from a single histogram data point +func (a *AccountDurationAggregator) processDataPoint(dataPoint metricdata.HistogramDataPoint[int64], now time.Time, p95s *[]int64) { + accountID := extractAccountID(dataPoint) + if accountID == "" { + return + } + + if p95 := calculateP95FromHistogram(dataPoint); p95 > 0 { + *p95s = append(*p95s, p95) + } +} + +// cleanupStaleAccounts removes accounts that haven't been updated recently +func (a *AccountDurationAggregator) cleanupStaleAccounts(now time.Time) { + for accountID := range a.accounts { + if a.isStaleAccount(accountID, now) { + delete(a.accounts, accountID) + } + } +} + +// extractAccountID retrieves the account_id from histogram data point attributes +func extractAccountID(dp metricdata.HistogramDataPoint[int64]) string { + for _, attr := range dp.Attributes.ToSlice() { + if attr.Key == "account_id" { + return attr.Value.AsString() + } + } + return "" +} + +// isStaleAccount checks if an account hasn't been updated recently +func (a *AccountDurationAggregator) isStaleAccount(accountID string, now time.Time) bool { + accHist, exists := a.accounts[accountID] + if !exists { + return false + } + return now.Sub(accHist.lastUpdate) > a.MaxAge +} + +// calculateP95FromHistogram computes P95 from OTel histogram data +func calculateP95FromHistogram(dp metricdata.HistogramDataPoint[int64]) int64 { + if dp.Count == 0 { + return 0 + } + + targetCount := uint64(math.Ceil(float64(dp.Count) * 0.95)) + if targetCount == 0 { + targetCount = 1 + } + var cumulativeCount uint64 + + for i, bucketCount := range dp.BucketCounts { + cumulativeCount += bucketCount + if cumulativeCount >= targetCount { + if i < len(dp.Bounds) { + return int64(dp.Bounds[i]) + } + if maxVal, defined := dp.Max.Value(); defined { + return maxVal + } + return dp.Sum / int64(dp.Count) + } + } + + return dp.Sum / int64(dp.Count) +} + +// Shutdown cleans up resources +func (a *AccountDurationAggregator) Shutdown() error { + return a.meterProvider.Shutdown(a.ctx) +} diff --git a/management/server/telemetry/account_aggregator_test.go b/management/server/telemetry/account_aggregator_test.go new file mode 100644 index 000000000..63b74b1db --- /dev/null +++ b/management/server/telemetry/account_aggregator_test.go @@ -0,0 +1,219 @@ +package telemetry + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDeltaTemporality_P95ReflectsCurrentWindow(t *testing.T) { + // Verify that with delta temporality, each flush window only reflects + // recordings since the last flush — not all-time data. + ctx := context.Background() + agg := NewAccountDurationAggregator(ctx, time.Minute, 5*time.Minute) + defer func(agg *AccountDurationAggregator) { + err := agg.Shutdown() + if err != nil { + t.Errorf("failed to shutdown aggregator: %v", err) + } + }(agg) + + // Window 1: Record 100 slow requests (500ms each) + for range 100 { + agg.Record("account-A", 500*time.Millisecond) + } + + p95sWindow1 := agg.FlushAndGetP95s() + require.Len(t, p95sWindow1, 1, "should have P95 for one account") + firstP95 := p95sWindow1[0] + assert.GreaterOrEqual(t, firstP95, int64(200), + "first window P95 should reflect the 500ms recordings") + + // Window 2: Record 100 FAST requests (10ms each) + for range 100 { + agg.Record("account-A", 10*time.Millisecond) + } + + p95sWindow2 := agg.FlushAndGetP95s() + require.Len(t, p95sWindow2, 1, "should have P95 for one account") + secondP95 := p95sWindow2[0] + + // With delta temporality the P95 should drop significantly because + // the first window's slow recordings are no longer included. + assert.Less(t, secondP95, firstP95, + "second window P95 should be lower than first — delta temporality "+ + "ensures each window only reflects recent recordings") +} + +func TestEqualWeightPerAccount(t *testing.T) { + // Verify that each account contributes exactly one P95 value, + // regardless of how many requests it made. + ctx := context.Background() + agg := NewAccountDurationAggregator(ctx, time.Minute, 5*time.Minute) + defer func(agg *AccountDurationAggregator) { + err := agg.Shutdown() + if err != nil { + t.Errorf("failed to shutdown aggregator: %v", err) + } + }(agg) + + // Account A: 10,000 requests at 500ms (noisy customer) + for range 10000 { + agg.Record("account-A", 500*time.Millisecond) + } + + // Accounts B, C, D: 10 requests each at 50ms (normal customers) + for _, id := range []string{"account-B", "account-C", "account-D"} { + for range 10 { + agg.Record(id, 50*time.Millisecond) + } + } + + p95s := agg.FlushAndGetP95s() + + // Should get exactly 4 P95 values — one per account + assert.Len(t, p95s, 4, "each account should contribute exactly one P95") +} + +func TestStaleAccountEviction(t *testing.T) { + ctx := context.Background() + // Use a very short MaxAge so we can test staleness + agg := NewAccountDurationAggregator(ctx, time.Minute, 50*time.Millisecond) + defer func(agg *AccountDurationAggregator) { + err := agg.Shutdown() + if err != nil { + t.Errorf("failed to shutdown aggregator: %v", err) + } + }(agg) + + agg.Record("account-A", 100*time.Millisecond) + agg.Record("account-B", 200*time.Millisecond) + + // Both accounts should appear + p95s := agg.FlushAndGetP95s() + assert.Len(t, p95s, 2, "both accounts should have P95 values") + + // Wait for account-A to become stale, then only update account-B + time.Sleep(60 * time.Millisecond) + agg.Record("account-B", 200*time.Millisecond) + + p95s = agg.FlushAndGetP95s() + assert.Len(t, p95s, 1, "both accounts should have P95 values") + + // account-A should have been evicted from the accounts map + agg.mu.RLock() + _, accountAExists := agg.accounts["account-A"] + _, accountBExists := agg.accounts["account-B"] + agg.mu.RUnlock() + + assert.False(t, accountAExists, "stale account-A should be evicted from map") + assert.True(t, accountBExists, "active account-B should remain in map") +} + +func TestStaleAccountEviction_DoesNotReappear(t *testing.T) { + // Verify that with delta temporality, an evicted stale account does not + // reappear in subsequent flushes. + ctx := context.Background() + agg := NewAccountDurationAggregator(ctx, time.Minute, 50*time.Millisecond) + defer func(agg *AccountDurationAggregator) { + err := agg.Shutdown() + if err != nil { + t.Errorf("failed to shutdown aggregator: %v", err) + } + }(agg) + + agg.Record("account-stale", 100*time.Millisecond) + + // Wait for it to become stale + time.Sleep(60 * time.Millisecond) + + // First flush: should detect staleness and evict + _ = agg.FlushAndGetP95s() + + agg.mu.RLock() + _, exists := agg.accounts["account-stale"] + agg.mu.RUnlock() + assert.False(t, exists, "account should be evicted after first flush") + + // Second flush: with delta temporality, the stale account should NOT reappear + p95sSecond := agg.FlushAndGetP95s() + assert.Empty(t, p95sSecond, + "evicted account should not reappear in subsequent flushes with delta temporality") +} + +func TestP95Calculation_SingleSample(t *testing.T) { + ctx := context.Background() + agg := NewAccountDurationAggregator(ctx, time.Minute, 5*time.Minute) + defer func(agg *AccountDurationAggregator) { + err := agg.Shutdown() + if err != nil { + t.Errorf("failed to shutdown aggregator: %v", err) + } + }(agg) + + agg.Record("account-A", 150*time.Millisecond) + + p95s := agg.FlushAndGetP95s() + require.Len(t, p95s, 1) + // With a single sample, P95 should be the bucket bound containing 150ms + assert.Greater(t, p95s[0], int64(0), "P95 of a single sample should be positive") +} + +func TestP95Calculation_AllSameValue(t *testing.T) { + ctx := context.Background() + agg := NewAccountDurationAggregator(ctx, time.Minute, 5*time.Minute) + defer func(agg *AccountDurationAggregator) { + err := agg.Shutdown() + if err != nil { + t.Errorf("failed to shutdown aggregator: %v", err) + } + }(agg) + + // All samples are 100ms — P95 should be the bucket bound containing 100ms + for range 100 { + agg.Record("account-A", 100*time.Millisecond) + } + + p95s := agg.FlushAndGetP95s() + require.Len(t, p95s, 1) + assert.Greater(t, p95s[0], int64(0)) +} + +func TestMultipleAccounts_IndependentP95s(t *testing.T) { + ctx := context.Background() + agg := NewAccountDurationAggregator(ctx, time.Minute, 5*time.Minute) + defer func(agg *AccountDurationAggregator) { + err := agg.Shutdown() + if err != nil { + t.Errorf("failed to shutdown aggregator: %v", err) + } + }(agg) + + // Account A: all fast (10ms) + for range 100 { + agg.Record("account-fast", 10*time.Millisecond) + } + + // Account B: all slow (5000ms) + for range 100 { + agg.Record("account-slow", 5000*time.Millisecond) + } + + p95s := agg.FlushAndGetP95s() + require.Len(t, p95s, 2, "should have two P95 values") + + // Find min and max — they should differ significantly + minP95 := p95s[0] + maxP95 := p95s[1] + if minP95 > maxP95 { + minP95, maxP95 = maxP95, minP95 + } + + assert.Less(t, minP95, int64(1000), + "fast account P95 should be well under 1000ms") + assert.Greater(t, maxP95, int64(1000), + "slow account P95 should be well over 1000ms") +} diff --git a/management/server/telemetry/app_metrics.go b/management/server/telemetry/app_metrics.go index 988f91779..1fd78bc3a 100644 --- a/management/server/telemetry/app_metrics.go +++ b/management/server/telemetry/app_metrics.go @@ -122,6 +122,7 @@ type defaultAppMetrics struct { Meter metric2.Meter listener net.Listener ctx context.Context + externallyManaged bool idpMetrics *IDPMetrics httpMiddleware *HTTPMiddleware grpcMetrics *GRPCMetrics @@ -171,6 +172,9 @@ func (appMetrics *defaultAppMetrics) Close() error { // Expose metrics on a given port and endpoint. If endpoint is empty a defaultEndpoint one will be used. // Exposes metrics in the Prometheus format https://prometheus.io/ func (appMetrics *defaultAppMetrics) Expose(ctx context.Context, port int, endpoint string) error { + if appMetrics.externallyManaged { + return nil + } if endpoint == "" { endpoint = defaultEndpoint } @@ -252,3 +256,49 @@ func NewDefaultAppMetrics(ctx context.Context) (AppMetrics, error) { accountManagerMetrics: accountManagerMetrics, }, nil } + +// NewAppMetricsWithMeter creates AppMetrics using an externally provided meter. +// The caller is responsible for exposing metrics via HTTP. Expose() and Close() are no-ops. +func NewAppMetricsWithMeter(ctx context.Context, meter metric2.Meter) (AppMetrics, error) { + idpMetrics, err := NewIDPMetrics(ctx, meter) + if err != nil { + return nil, fmt.Errorf("failed to initialize IDP metrics: %w", err) + } + + middleware, err := NewMetricsMiddleware(ctx, meter) + if err != nil { + return nil, fmt.Errorf("failed to initialize HTTP middleware metrics: %w", err) + } + + grpcMetrics, err := NewGRPCMetrics(ctx, meter) + if err != nil { + return nil, fmt.Errorf("failed to initialize gRPC metrics: %w", err) + } + + storeMetrics, err := NewStoreMetrics(ctx, meter) + if err != nil { + return nil, fmt.Errorf("failed to initialize store metrics: %w", err) + } + + updateChannelMetrics, err := NewUpdateChannelMetrics(ctx, meter) + if err != nil { + return nil, fmt.Errorf("failed to initialize update channel metrics: %w", err) + } + + accountManagerMetrics, err := NewAccountManagerMetrics(ctx, meter) + if err != nil { + return nil, fmt.Errorf("failed to initialize account manager metrics: %w", err) + } + + return &defaultAppMetrics{ + Meter: meter, + ctx: ctx, + externallyManaged: true, + idpMetrics: idpMetrics, + httpMiddleware: middleware, + grpcMetrics: grpcMetrics, + storeMetrics: storeMetrics, + updateChannelMetrics: updateChannelMetrics, + accountManagerMetrics: accountManagerMetrics, + }, nil +} diff --git a/management/server/telemetry/grpc_metrics.go b/management/server/telemetry/grpc_metrics.go index bd7fbc235..d3239c57a 100644 --- a/management/server/telemetry/grpc_metrics.go +++ b/management/server/telemetry/grpc_metrics.go @@ -13,18 +13,24 @@ const HighLatencyThreshold = time.Second * 7 // GRPCMetrics are gRPC server metrics type GRPCMetrics struct { - meter metric.Meter - syncRequestsCounter metric.Int64Counter - syncRequestsBlockedCounter metric.Int64Counter - loginRequestsCounter metric.Int64Counter - loginRequestsBlockedCounter metric.Int64Counter - loginRequestHighLatencyCounter metric.Int64Counter - getKeyRequestsCounter metric.Int64Counter - activeStreamsGauge metric.Int64ObservableGauge - syncRequestDuration metric.Int64Histogram - loginRequestDuration metric.Int64Histogram - channelQueueLength metric.Int64Histogram - ctx context.Context + meter metric.Meter + syncRequestsCounter metric.Int64Counter + syncRequestsBlockedCounter metric.Int64Counter + loginRequestsCounter metric.Int64Counter + loginRequestsBlockedCounter metric.Int64Counter + loginRequestHighLatencyCounter metric.Int64Counter + getKeyRequestsCounter metric.Int64Counter + activeStreamsGauge metric.Int64ObservableGauge + syncRequestDuration metric.Int64Histogram + syncRequestDurationP95ByAccount metric.Int64Histogram + loginRequestDuration metric.Int64Histogram + loginRequestDurationP95ByAccount metric.Int64Histogram + channelQueueLength metric.Int64Histogram + ctx context.Context + + // Per-account aggregation + syncDurationAggregator *AccountDurationAggregator + loginDurationAggregator *AccountDurationAggregator } // NewGRPCMetrics creates new GRPCMetrics struct and registers common metrics of the gRPC server @@ -93,6 +99,14 @@ func NewGRPCMetrics(ctx context.Context, meter metric.Meter) (*GRPCMetrics, erro return nil, err } + syncRequestDurationP95ByAccount, err := meter.Int64Histogram("management.grpc.sync.request.duration.p95.by.account.ms", + metric.WithUnit("milliseconds"), + metric.WithDescription("P95 duration of sync requests aggregated per account - each data point represents one account's P95"), + ) + if err != nil { + return nil, err + } + loginRequestDuration, err := meter.Int64Histogram("management.grpc.login.request.duration.ms", metric.WithUnit("milliseconds"), metric.WithDescription("Duration of the login gRPC requests from the peers to authenticate and receive initial configuration and relay credentials"), @@ -101,6 +115,14 @@ func NewGRPCMetrics(ctx context.Context, meter metric.Meter) (*GRPCMetrics, erro return nil, err } + loginRequestDurationP95ByAccount, err := meter.Int64Histogram("management.grpc.login.request.duration.p95.by.account.ms", + metric.WithUnit("milliseconds"), + metric.WithDescription("P95 duration of login requests aggregated per account - each data point represents one account's P95"), + ) + if err != nil { + return nil, err + } + // We use histogram here as we have multiple channel at the same time and we want to see a slice at any given time // Then we should be able to extract min, manx, mean and the percentiles. // TODO(yury): This needs custom bucketing as we are interested in the values from 0 to server.channelBufferSize (100) @@ -113,20 +135,32 @@ func NewGRPCMetrics(ctx context.Context, meter metric.Meter) (*GRPCMetrics, erro return nil, err } - return &GRPCMetrics{ - meter: meter, - syncRequestsCounter: syncRequestsCounter, - syncRequestsBlockedCounter: syncRequestsBlockedCounter, - loginRequestsCounter: loginRequestsCounter, - loginRequestsBlockedCounter: loginRequestsBlockedCounter, - loginRequestHighLatencyCounter: loginRequestHighLatencyCounter, - getKeyRequestsCounter: getKeyRequestsCounter, - activeStreamsGauge: activeStreamsGauge, - syncRequestDuration: syncRequestDuration, - loginRequestDuration: loginRequestDuration, - channelQueueLength: channelQueue, - ctx: ctx, - }, err + syncDurationAggregator := NewAccountDurationAggregator(ctx, 60*time.Second, 5*time.Minute) + loginDurationAggregator := NewAccountDurationAggregator(ctx, 60*time.Second, 5*time.Minute) + + grpcMetrics := &GRPCMetrics{ + meter: meter, + syncRequestsCounter: syncRequestsCounter, + syncRequestsBlockedCounter: syncRequestsBlockedCounter, + loginRequestsCounter: loginRequestsCounter, + loginRequestsBlockedCounter: loginRequestsBlockedCounter, + loginRequestHighLatencyCounter: loginRequestHighLatencyCounter, + getKeyRequestsCounter: getKeyRequestsCounter, + activeStreamsGauge: activeStreamsGauge, + syncRequestDuration: syncRequestDuration, + syncRequestDurationP95ByAccount: syncRequestDurationP95ByAccount, + loginRequestDuration: loginRequestDuration, + loginRequestDurationP95ByAccount: loginRequestDurationP95ByAccount, + channelQueueLength: channelQueue, + ctx: ctx, + syncDurationAggregator: syncDurationAggregator, + loginDurationAggregator: loginDurationAggregator, + } + + go grpcMetrics.startSyncP95Flusher() + go grpcMetrics.startLoginP95Flusher() + + return grpcMetrics, err } // CountSyncRequest counts the number of gRPC sync requests coming to the gRPC API @@ -157,6 +191,9 @@ func (grpcMetrics *GRPCMetrics) CountLoginRequestBlocked() { // CountLoginRequestDuration counts the duration of the login gRPC requests func (grpcMetrics *GRPCMetrics) CountLoginRequestDuration(duration time.Duration, accountID string) { grpcMetrics.loginRequestDuration.Record(grpcMetrics.ctx, duration.Milliseconds()) + + grpcMetrics.loginDurationAggregator.Record(accountID, duration) + if duration > HighLatencyThreshold { grpcMetrics.loginRequestHighLatencyCounter.Add(grpcMetrics.ctx, 1, metric.WithAttributes(attribute.String(AccountIDLabel, accountID))) } @@ -165,6 +202,44 @@ func (grpcMetrics *GRPCMetrics) CountLoginRequestDuration(duration time.Duration // CountSyncRequestDuration counts the duration of the sync gRPC requests func (grpcMetrics *GRPCMetrics) CountSyncRequestDuration(duration time.Duration, accountID string) { grpcMetrics.syncRequestDuration.Record(grpcMetrics.ctx, duration.Milliseconds()) + + grpcMetrics.syncDurationAggregator.Record(accountID, duration) +} + +// startSyncP95Flusher periodically flushes per-account sync P95 values to the histogram +func (grpcMetrics *GRPCMetrics) startSyncP95Flusher() { + ticker := time.NewTicker(grpcMetrics.syncDurationAggregator.FlushInterval) + defer ticker.Stop() + + for { + select { + case <-grpcMetrics.ctx.Done(): + return + case <-ticker.C: + p95s := grpcMetrics.syncDurationAggregator.FlushAndGetP95s() + for _, p95 := range p95s { + grpcMetrics.syncRequestDurationP95ByAccount.Record(grpcMetrics.ctx, p95) + } + } + } +} + +// startLoginP95Flusher periodically flushes per-account login P95 values to the histogram +func (grpcMetrics *GRPCMetrics) startLoginP95Flusher() { + ticker := time.NewTicker(grpcMetrics.loginDurationAggregator.FlushInterval) + defer ticker.Stop() + + for { + select { + case <-grpcMetrics.ctx.Done(): + return + case <-ticker.C: + p95s := grpcMetrics.loginDurationAggregator.FlushAndGetP95s() + for _, p95 := range p95s { + grpcMetrics.loginRequestDurationP95ByAccount.Record(grpcMetrics.ctx, p95) + } + } + } } // RegisterConnectedStreams registers a function that collects number of active streams and feeds it to the metrics gauge. diff --git a/management/server/testdata/auth_callback.sql b/management/server/testdata/auth_callback.sql new file mode 100644 index 000000000..fdd91a6d5 --- /dev/null +++ b/management/server/testdata/auth_callback.sql @@ -0,0 +1,17 @@ +-- Schema definitions (must match GORM auto-migrate order) +CREATE TABLE `accounts` (`id` text,`created_by` text,`created_at` datetime,`domain` text,`domain_category` text,`is_domain_primary_account` numeric,`network_identifier` text,`network_net` text,`network_dns` text,`network_serial` integer,`dns_settings_disabled_management_groups` text,`settings_peer_login_expiration_enabled` numeric,`settings_peer_login_expiration` integer,`settings_regular_users_view_blocked` numeric,`settings_groups_propagation_enabled` numeric,`settings_jwt_groups_enabled` numeric,`settings_jwt_groups_claim_name` text,`settings_jwt_allow_groups` text,`settings_extra_peer_approval_enabled` numeric,`settings_extra_integrated_validator_groups` text,PRIMARY KEY (`id`)); +CREATE TABLE `groups` (`id` text,`account_id` text,`name` text,`issued` text,`peers` text,`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_groups_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `users` (`id` text,`account_id` text,`role` text,`is_service_user` numeric,`non_deletable` numeric,`service_user_name` text,`auto_groups` text,`blocked` numeric,`last_login` datetime DEFAULT NULL,`created_at` datetime,`issued` text DEFAULT "api",`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_users_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); + +-- Test accounts +INSERT INTO accounts VALUES('testAccountId','','2024-10-02 16:01:38.000000000+00:00','test.com','private',1,'testNetworkIdentifier','{"IP":"100.64.0.0","Mask":"//8AAA=="}','',0,'[]',0,86400000000000,0,0,0,'',NULL,NULL,NULL); +INSERT INTO accounts VALUES('otherAccountId','','2024-10-02 16:01:38.000000000+00:00','other.com','private',1,'otherNetworkIdentifier','{"IP":"100.64.0.0","Mask":"//8AAA=="}','',0,'[]',0,86400000000000,0,0,0,'',NULL,NULL,NULL); + +-- Test groups +INSERT INTO "groups" VALUES('allowedGroupId','testAccountId','Allowed Group','api','[]',0,''); +INSERT INTO "groups" VALUES('restrictedGroupId','testAccountId','Restricted Group','api','[]',0,''); + +-- Test users +INSERT INTO users VALUES('allowedUserId','testAccountId','user',0,0,'','["allowedGroupId"]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('nonGroupUserId','testAccountId','user',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('otherAccountUserId','otherAccountId','user',0,0,'','["allowedGroupId"]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); diff --git a/management/server/testutil/store.go b/management/server/testutil/store.go index f92153399..07699e2c3 100644 --- a/management/server/testutil/store.go +++ b/management/server/testutil/store.go @@ -32,8 +32,8 @@ func CreateMysqlTestContainer() (func(), string, error) { } var err error - mysqlContainer, err = mysql.RunContainer(ctx, - testcontainers.WithImage("mlsmaycon/warmed-mysql:8"), + mysqlContainer, err = mysql.Run(ctx, + "mlsmaycon/warmed-mysql:8", mysql.WithDatabase("testing"), mysql.WithUsername("root"), mysql.WithPassword("testing"), @@ -78,8 +78,8 @@ func CreatePostgresTestContainer() (func(), string, error) { } var err error - pgContainer, err = postgres.RunContainer(ctx, - testcontainers.WithImage("postgres:16-alpine"), + pgContainer, err = postgres.Run(ctx, + "postgres:16-alpine", postgres.WithDatabase("netbird"), postgres.WithUsername("root"), postgres.WithPassword("netbird"), @@ -120,7 +120,7 @@ func noOpCleanup() { func CreateRedisTestContainer() (func(), string, error) { ctx := context.Background() - redisContainer, err := testcontainersredis.RunContainer(ctx, testcontainers.WithImage("redis:7")) + redisContainer, err := testcontainersredis.Run(ctx, "redis:7") if err != nil { return nil, "", err } diff --git a/management/server/types/account.go b/management/server/types/account.go index a2b5140d4..269fc7a88 100644 --- a/management/server/types/account.go +++ b/management/server/types/account.go @@ -18,6 +18,7 @@ import ( "github.com/netbirdio/netbird/client/ssh/auth" nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" "github.com/netbirdio/netbird/management/internals/modules/zones" "github.com/netbirdio/netbird/management/internals/modules/zones/records" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" @@ -99,6 +100,7 @@ type Account struct { NameServerGroupsG []nbdns.NameServerGroup `json:"-" gorm:"foreignKey:AccountID;references:id"` DNSSettings DNSSettings `gorm:"embedded;embeddedPrefix:dns_settings_"` PostureChecks []*posture.Checks `gorm:"foreignKey:AccountID;references:id"` + Services []*service.Service `gorm:"foreignKey:AccountID;references:id"` // Settings is a dictionary of Account settings Settings *Settings `gorm:"embedded;embeddedPrefix:settings_"` Networks []*networkTypes.Network `gorm:"foreignKey:AccountID;references:id"` @@ -108,6 +110,8 @@ type Account struct { NetworkMapCache *NetworkMapBuilder `gorm:"-"` nmapInitOnce *sync.Once `gorm:"-"` + + ReverseProxyFreeDomainNonce string } func (a *Account) InitOnce() { @@ -902,6 +906,11 @@ func (a *Account) Copy() *Account { networkResources = append(networkResources, resource.Copy()) } + services := []*service.Service{} + for _, svc := range a.Services { + services = append(services, svc.Copy()) + } + return &Account{ Id: a.Id, CreatedBy: a.CreatedBy, @@ -923,6 +932,7 @@ func (a *Account) Copy() *Account { Networks: nets, NetworkRouters: networkRouters, NetworkResources: networkResources, + Services: services, Onboarding: a.Onboarding, NetworkMapCache: a.NetworkMapCache, nmapInitOnce: a.nmapInitOnce, @@ -1213,7 +1223,7 @@ func (a *Account) getAllPeersFromGroups(ctx context.Context, groups []string, pe filteredPeers := make([]*nbpeer.Peer, 0, len(uniquePeerIDs)) for _, p := range uniquePeerIDs { peer, ok := a.Peers[p] - if !ok || peer == nil { + if !ok || peer == nil || peer.ProxyMeta.Embedded { continue } @@ -1595,12 +1605,12 @@ func (a *Account) GetPoliciesForNetworkResource(resourceId string) []*Policy { networkResourceGroups := a.getNetworkResourceGroups(resourceId) for _, policy := range a.Policies { - if !policy.Enabled { + if policy == nil || !policy.Enabled { continue } for _, rule := range policy.Rules { - if !rule.Enabled { + if rule == nil || !rule.Enabled { continue } @@ -1776,6 +1786,119 @@ func (a *Account) GetActiveGroupUsers() map[string][]string { return groups } +func (a *Account) GetProxyPeers() map[string][]*nbpeer.Peer { + proxyPeers := make(map[string][]*nbpeer.Peer) + for _, peer := range a.Peers { + if peer.ProxyMeta.Embedded { + proxyPeers[peer.ProxyMeta.Cluster] = append(proxyPeers[peer.ProxyMeta.Cluster], peer) + } + } + return proxyPeers +} + +func (a *Account) InjectProxyPolicies(ctx context.Context) { + if len(a.Services) == 0 { + return + } + + proxyPeersByCluster := a.GetProxyPeers() + if len(proxyPeersByCluster) == 0 { + return + } + + for _, service := range a.Services { + if !service.Enabled { + continue + } + a.injectServiceProxyPolicies(ctx, service, proxyPeersByCluster) + } + +} + +func (a *Account) injectServiceProxyPolicies(ctx context.Context, service *service.Service, proxyPeersByCluster map[string][]*nbpeer.Peer) { + proxyPeers := proxyPeersByCluster[service.ProxyCluster] + for _, target := range service.Targets { + if !target.Enabled { + continue + } + a.injectTargetProxyPolicies(ctx, service, target, proxyPeers) + } + +} + +func (a *Account) injectTargetProxyPolicies(ctx context.Context, service *service.Service, target *service.Target, proxyPeers []*nbpeer.Peer) { + port, ok := a.resolveTargetPort(ctx, target) + if !ok { + return + } + + path := "" + if target.Path != nil { + path = *target.Path + } + + for _, proxyPeer := range proxyPeers { + policy := a.createProxyPolicy(service, target, proxyPeer, port, path) + a.Policies = append(a.Policies, policy) + } +} + +func (a *Account) resolveTargetPort(ctx context.Context, target *service.Target) (uint16, bool) { + if target.Port != 0 { + return target.Port, true + } + + switch target.Protocol { + case "https", "tls": + return 443, true + case "http": + return 80, true + default: + log.WithContext(ctx).Warnf("unsupported protocol %s for proxy target %s, skipping policy injection", target.Protocol, target.TargetId) + return 0, false + } +} + +func (a *Account) createProxyPolicy(svc *service.Service, target *service.Target, proxyPeer *nbpeer.Peer, port uint16, path string) *Policy { + policyID := fmt.Sprintf("proxy-access-%s-%s-%s", svc.ID, proxyPeer.ID, path) + + protocol := PolicyRuleProtocolTCP + if svc.Mode == service.ModeUDP { + protocol = PolicyRuleProtocolUDP + } + + return &Policy{ + ID: policyID, + Name: fmt.Sprintf("Proxy Access to %s", svc.Name), + Enabled: true, + Rules: []*PolicyRule{ + { + ID: policyID, + PolicyID: policyID, + Name: fmt.Sprintf("Allow access to %s", svc.Name), + Enabled: true, + SourceResource: Resource{ + ID: proxyPeer.ID, + Type: ResourceTypePeer, + }, + DestinationResource: Resource{ + ID: target.TargetId, + Type: ResourceType(target.TargetType), + }, + Bidirectional: false, + Protocol: protocol, + Action: PolicyTrafficActionAccept, + PortRanges: []RulePortRange{ + { + Start: port, + End: port, + }, + }, + }, + }, + } +} + // expandPortsAndRanges expands Ports and PortRanges of a rule into individual firewall rules func expandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *nbpeer.Peer) []*FirewallRule { features := peerSupportedFirewallFeatures(peer.Meta.WtVersion) diff --git a/management/server/types/account_components.go b/management/server/types/account_components.go new file mode 100644 index 000000000..bd4244546 --- /dev/null +++ b/management/server/types/account_components.go @@ -0,0 +1,576 @@ +package types + +import ( + "context" + "slices" + "time" + + log "github.com/sirupsen/logrus" + + nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/internals/modules/zones" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/telemetry" + "github.com/netbirdio/netbird/route" +) + +func (a *Account) GetPeerNetworkMapFromComponents( + ctx context.Context, + peerID string, + peersCustomZone nbdns.CustomZone, + accountZones []*zones.Zone, + validatedPeersMap map[string]struct{}, + resourcePolicies map[string][]*Policy, + routers map[string]map[string]*routerTypes.NetworkRouter, + metrics *telemetry.AccountManagerMetrics, + groupIDToUserIDs map[string][]string, +) *NetworkMap { + start := time.Now() + + components := a.GetPeerNetworkMapComponents( + ctx, + peerID, + peersCustomZone, + accountZones, + validatedPeersMap, + resourcePolicies, + routers, + groupIDToUserIDs, + ) + + if components == nil { + return &NetworkMap{Network: a.Network.Copy()} + } + + nm := CalculateNetworkMapFromComponents(ctx, components) + + if metrics != nil { + objectCount := int64(len(nm.Peers) + len(nm.OfflinePeers) + len(nm.Routes) + len(nm.FirewallRules) + len(nm.RoutesFirewallRules)) + metrics.CountNetworkMapObjects(objectCount) + metrics.CountGetPeerNetworkMapDuration(time.Since(start)) + + if objectCount > 5000 { + log.WithContext(ctx).Tracef("account: %s has a total resource count of %d objects from components, "+ + "peers: %d, offline peers: %d, routes: %d, firewall rules: %d, route firewall rules: %d", + a.Id, objectCount, len(nm.Peers), len(nm.OfflinePeers), len(nm.Routes), len(nm.FirewallRules), len(nm.RoutesFirewallRules)) + } + } + + return nm +} + +func (a *Account) GetPeerNetworkMapComponents( + ctx context.Context, + peerID string, + peersCustomZone nbdns.CustomZone, + accountZones []*zones.Zone, + validatedPeersMap map[string]struct{}, + resourcePolicies map[string][]*Policy, + routers map[string]map[string]*routerTypes.NetworkRouter, + groupIDToUserIDs map[string][]string, +) *NetworkMapComponents { + + peer := a.Peers[peerID] + if peer == nil { + return nil + } + + if _, ok := validatedPeersMap[peerID]; !ok { + return nil + } + + components := &NetworkMapComponents{ + PeerID: peerID, + Network: a.Network.Copy(), + NameServerGroups: make([]*nbdns.NameServerGroup, 0), + CustomZoneDomain: peersCustomZone.Domain, + ResourcePoliciesMap: make(map[string][]*Policy), + RoutersMap: make(map[string]map[string]*routerTypes.NetworkRouter), + NetworkResources: make([]*resourceTypes.NetworkResource, 0), + PostureFailedPeers: make(map[string]map[string]struct{}, len(a.PostureChecks)), + RouterPeers: make(map[string]*nbpeer.Peer), + } + + components.AccountSettings = &AccountSettingsInfo{ + PeerLoginExpirationEnabled: a.Settings.PeerLoginExpirationEnabled, + PeerLoginExpiration: a.Settings.PeerLoginExpiration, + PeerInactivityExpirationEnabled: a.Settings.PeerInactivityExpirationEnabled, + PeerInactivityExpiration: a.Settings.PeerInactivityExpiration, + } + + components.DNSSettings = &a.DNSSettings + + relevantPeers, relevantGroups, relevantPolicies, relevantRoutes, sshReqs := a.getPeersGroupsPoliciesRoutes(ctx, peerID, peer.SSHEnabled, validatedPeersMap, &components.PostureFailedPeers) + + if len(sshReqs.neededGroupIDs) > 0 { + components.GroupIDToUserIDs = filterGroupIDToUserIDs(groupIDToUserIDs, sshReqs.neededGroupIDs) + } + if sshReqs.needAllowedUserIDs { + components.AllowedUserIDs = a.getAllowedUserIDs() + } + + components.Peers = relevantPeers + components.Groups = relevantGroups + components.Policies = relevantPolicies + components.Routes = relevantRoutes + components.AllDNSRecords = filterDNSRecordsByPeers(peersCustomZone.Records, relevantPeers) + + peerGroups := a.GetPeerGroups(peerID) + components.AccountZones = filterPeerAppliedZones(ctx, accountZones, peerGroups) + + for _, nsGroup := range a.NameServerGroups { + if nsGroup.Enabled { + for _, gID := range nsGroup.Groups { + if _, found := relevantGroups[gID]; found { + components.NameServerGroups = append(components.NameServerGroups, nsGroup) + break + } + } + } + } + + for _, resource := range a.NetworkResources { + if !resource.Enabled { + continue + } + + policies, exists := resourcePolicies[resource.ID] + if !exists { + continue + } + + addSourcePeers := false + + networkRoutingPeers, routerExists := routers[resource.NetworkID] + if routerExists { + if _, ok := networkRoutingPeers[peerID]; ok { + addSourcePeers = true + } + } + + for _, policy := range policies { + if addSourcePeers { + var peers []string + if policy.Rules[0].SourceResource.Type == ResourceTypePeer && policy.Rules[0].SourceResource.ID != "" { + peers = []string{policy.Rules[0].SourceResource.ID} + } else { + peers = a.getUniquePeerIDsFromGroupsIDs(ctx, policy.SourceGroups()) + } + for _, pID := range a.getPostureValidPeersSaveFailed(peers, policy.SourcePostureChecks, validatedPeersMap, &components.PostureFailedPeers) { + if _, exists := components.Peers[pID]; !exists { + components.Peers[pID] = a.GetPeer(pID) + } + } + } else { + peerInSources := false + if policy.Rules[0].SourceResource.Type == ResourceTypePeer && policy.Rules[0].SourceResource.ID != "" { + peerInSources = policy.Rules[0].SourceResource.ID == peerID + } else { + for _, groupID := range policy.SourceGroups() { + if group := a.GetGroup(groupID); group != nil && slices.Contains(group.Peers, peerID) { + peerInSources = true + break + } + } + } + if !peerInSources { + continue + } + isValid, pname := a.validatePostureChecksOnPeerGetFailed(ctx, policy.SourcePostureChecks, peerID) + if !isValid && len(pname) > 0 { + if _, ok := components.PostureFailedPeers[pname]; !ok { + components.PostureFailedPeers[pname] = make(map[string]struct{}) + } + components.PostureFailedPeers[pname][peer.ID] = struct{}{} + continue + } + addSourcePeers = true + } + + for _, rule := range policy.Rules { + for _, srcGroupID := range rule.Sources { + if g := a.Groups[srcGroupID]; g != nil { + if _, exists := components.Groups[srcGroupID]; !exists { + components.Groups[srcGroupID] = g + } + } + } + for _, dstGroupID := range rule.Destinations { + if g := a.Groups[dstGroupID]; g != nil { + if _, exists := components.Groups[dstGroupID]; !exists { + components.Groups[dstGroupID] = g + } + } + } + } + components.ResourcePoliciesMap[resource.ID] = policies + } + + components.RoutersMap[resource.NetworkID] = networkRoutingPeers + for peerIDKey := range networkRoutingPeers { + if p := a.Peers[peerIDKey]; p != nil { + if _, exists := components.RouterPeers[peerIDKey]; !exists { + components.RouterPeers[peerIDKey] = p + } + if _, exists := components.Peers[peerIDKey]; !exists { + if _, validated := validatedPeersMap[peerIDKey]; validated { + components.Peers[peerIDKey] = p + } + } + } + } + + if addSourcePeers { + components.NetworkResources = append(components.NetworkResources, resource) + } + } + + filterGroupPeers(&components.Groups, components.Peers) + filterPostureFailedPeers(&components.PostureFailedPeers, components.Policies, components.ResourcePoliciesMap, components.Peers) + + return components +} + +type sshRequirements struct { + neededGroupIDs map[string]struct{} + needAllowedUserIDs bool +} + +func (a *Account) getPeersGroupsPoliciesRoutes( + ctx context.Context, + peerID string, + peerSSHEnabled bool, + validatedPeersMap map[string]struct{}, + postureFailedPeers *map[string]map[string]struct{}, +) (map[string]*nbpeer.Peer, map[string]*Group, []*Policy, []*route.Route, sshRequirements) { + relevantPeerIDs := make(map[string]*nbpeer.Peer, len(a.Peers)/4) + relevantGroupIDs := make(map[string]*Group, len(a.Groups)/4) + relevantPolicies := make([]*Policy, 0, len(a.Policies)) + relevantRoutes := make([]*route.Route, 0, len(a.Routes)) + sshReqs := sshRequirements{neededGroupIDs: make(map[string]struct{})} + + relevantPeerIDs[peerID] = a.GetPeer(peerID) + + for groupID, group := range a.Groups { + if slices.Contains(group.Peers, peerID) { + relevantGroupIDs[groupID] = a.GetGroup(groupID) + } + } + + routeAccessControlGroups := make(map[string]struct{}) + for _, r := range a.Routes { + for _, groupID := range r.Groups { + relevantGroupIDs[groupID] = a.GetGroup(groupID) + } + for _, groupID := range r.PeerGroups { + relevantGroupIDs[groupID] = a.GetGroup(groupID) + } + if r.Enabled { + for _, groupID := range r.AccessControlGroups { + relevantGroupIDs[groupID] = a.GetGroup(groupID) + routeAccessControlGroups[groupID] = struct{}{} + } + } + relevantRoutes = append(relevantRoutes, r) + } + + for _, policy := range a.Policies { + if !policy.Enabled { + continue + } + + policyRelevant := false + for _, rule := range policy.Rules { + if !rule.Enabled { + continue + } + + if len(routeAccessControlGroups) > 0 { + for _, destGroupID := range rule.Destinations { + if _, needed := routeAccessControlGroups[destGroupID]; needed { + policyRelevant = true + for _, srcGroupID := range rule.Sources { + relevantGroupIDs[srcGroupID] = a.GetGroup(srcGroupID) + } + for _, dstGroupID := range rule.Destinations { + relevantGroupIDs[dstGroupID] = a.GetGroup(dstGroupID) + } + break + } + } + } + + var sourcePeers, destinationPeers []string + var peerInSources, peerInDestinations bool + + if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" { + sourcePeers = []string{rule.SourceResource.ID} + if rule.SourceResource.ID == peerID { + peerInSources = true + } + } else { + sourcePeers, peerInSources = a.getPeersFromGroups(ctx, rule.Sources, peerID, policy.SourcePostureChecks, validatedPeersMap, postureFailedPeers) + } + + if rule.DestinationResource.Type == ResourceTypePeer && rule.DestinationResource.ID != "" { + destinationPeers = []string{rule.DestinationResource.ID} + if rule.DestinationResource.ID == peerID { + peerInDestinations = true + } + } else { + destinationPeers, peerInDestinations = a.getPeersFromGroups(ctx, rule.Destinations, peerID, nil, validatedPeersMap, postureFailedPeers) + } + + if peerInSources { + policyRelevant = true + for _, pid := range destinationPeers { + relevantPeerIDs[pid] = a.GetPeer(pid) + } + for _, dstGroupID := range rule.Destinations { + relevantGroupIDs[dstGroupID] = a.GetGroup(dstGroupID) + } + } + + if peerInDestinations { + policyRelevant = true + for _, pid := range sourcePeers { + relevantPeerIDs[pid] = a.GetPeer(pid) + } + for _, srcGroupID := range rule.Sources { + relevantGroupIDs[srcGroupID] = a.GetGroup(srcGroupID) + } + + if rule.Protocol == PolicyRuleProtocolNetbirdSSH { + switch { + case len(rule.AuthorizedGroups) > 0: + for groupID := range rule.AuthorizedGroups { + sshReqs.neededGroupIDs[groupID] = struct{}{} + } + case rule.AuthorizedUser != "": + default: + sshReqs.needAllowedUserIDs = true + } + } else if policyRuleImpliesLegacySSH(rule) && peerSSHEnabled { + sshReqs.needAllowedUserIDs = true + } + } + } + if policyRelevant { + relevantPolicies = append(relevantPolicies, policy) + } + } + + return relevantPeerIDs, relevantGroupIDs, relevantPolicies, relevantRoutes, sshReqs +} + +func (a *Account) getPeersFromGroups(ctx context.Context, groups []string, peerID string, sourcePostureChecksIDs []string, + validatedPeersMap map[string]struct{}, postureFailedPeers *map[string]map[string]struct{}) ([]string, bool) { + peerInGroups := false + filteredPeerIDs := make([]string, 0, len(groups)) + seenPeerIds := make(map[string]struct{}, len(groups)) + + for _, gid := range groups { + group := a.GetGroup(gid) + if group == nil { + continue + } + + if group.IsGroupAll() || len(groups) == 1 { + filteredPeerIDs = make([]string, 0, len(group.Peers)) + peerInGroups = false + for _, pid := range group.Peers { + peer, ok := a.Peers[pid] + if !ok || peer == nil { + continue + } + + if _, ok := validatedPeersMap[peer.ID]; !ok { + continue + } + + isValid, pname := a.validatePostureChecksOnPeerGetFailed(ctx, sourcePostureChecksIDs, peer.ID) + if !isValid && len(pname) > 0 { + if _, ok := (*postureFailedPeers)[pname]; !ok { + (*postureFailedPeers)[pname] = make(map[string]struct{}) + } + (*postureFailedPeers)[pname][peer.ID] = struct{}{} + continue + } + + if peer.ID == peerID { + peerInGroups = true + continue + } + + filteredPeerIDs = append(filteredPeerIDs, peer.ID) + } + return filteredPeerIDs, peerInGroups + } + + for _, pid := range group.Peers { + if _, seen := seenPeerIds[pid]; seen { + continue + } + seenPeerIds[pid] = struct{}{} + peer, ok := a.Peers[pid] + if !ok || peer == nil { + continue + } + + if _, ok := validatedPeersMap[peer.ID]; !ok { + continue + } + + isValid, pname := a.validatePostureChecksOnPeerGetFailed(ctx, sourcePostureChecksIDs, peer.ID) + if !isValid && len(pname) > 0 { + if _, ok := (*postureFailedPeers)[pname]; !ok { + (*postureFailedPeers)[pname] = make(map[string]struct{}) + } + (*postureFailedPeers)[pname][peer.ID] = struct{}{} + continue + } + + if peer.ID == peerID { + peerInGroups = true + continue + } + + filteredPeerIDs = append(filteredPeerIDs, peer.ID) + } + } + + return filteredPeerIDs, peerInGroups +} + +func (a *Account) validatePostureChecksOnPeerGetFailed(ctx context.Context, sourcePostureChecksID []string, peerID string) (bool, string) { + peer, ok := a.Peers[peerID] + if !ok || peer == nil { + return false, "" + } + + for _, postureChecksID := range sourcePostureChecksID { + postureChecks := a.GetPostureChecks(postureChecksID) + if postureChecks == nil { + continue + } + + for _, check := range postureChecks.GetChecks() { + isValid, _ := check.Check(ctx, *peer) + if !isValid { + return false, postureChecksID + } + } + } + return true, "" +} + +func (a *Account) getPostureValidPeersSaveFailed(inputPeers []string, postureChecksIDs []string, validatedPeersMap map[string]struct{}, postureFailedPeers *map[string]map[string]struct{}) []string { + var dest []string + for _, peerID := range inputPeers { + if _, validated := validatedPeersMap[peerID]; !validated { + continue + } + valid, pname := a.validatePostureChecksOnPeerGetFailed(context.Background(), postureChecksIDs, peerID) + if valid { + dest = append(dest, peerID) + continue + } + if _, ok := (*postureFailedPeers)[pname]; !ok { + (*postureFailedPeers)[pname] = make(map[string]struct{}) + } + (*postureFailedPeers)[pname][peerID] = struct{}{} + } + return dest +} + +func filterGroupPeers(groups *map[string]*Group, peers map[string]*nbpeer.Peer) { + for groupID, groupInfo := range *groups { + filteredPeers := make([]string, 0, len(groupInfo.Peers)) + for _, pid := range groupInfo.Peers { + if _, exists := peers[pid]; exists { + filteredPeers = append(filteredPeers, pid) + } + } + + if len(filteredPeers) == 0 { + delete(*groups, groupID) + } else if len(filteredPeers) != len(groupInfo.Peers) { + ng := groupInfo.Copy() + ng.Peers = filteredPeers + (*groups)[groupID] = ng + } + } +} + +func filterPostureFailedPeers(postureFailedPeers *map[string]map[string]struct{}, policies []*Policy, resourcePoliciesMap map[string][]*Policy, peers map[string]*nbpeer.Peer) { + if len(*postureFailedPeers) == 0 { + return + } + + referencedPostureChecks := make(map[string]struct{}) + for _, policy := range policies { + for _, checkID := range policy.SourcePostureChecks { + referencedPostureChecks[checkID] = struct{}{} + } + } + for _, resPolicies := range resourcePoliciesMap { + for _, policy := range resPolicies { + for _, checkID := range policy.SourcePostureChecks { + referencedPostureChecks[checkID] = struct{}{} + } + } + } + + for checkID, failedPeers := range *postureFailedPeers { + if _, referenced := referencedPostureChecks[checkID]; !referenced { + delete(*postureFailedPeers, checkID) + continue + } + for peerID := range failedPeers { + if _, exists := peers[peerID]; !exists { + delete(failedPeers, peerID) + } + } + if len(failedPeers) == 0 { + delete(*postureFailedPeers, checkID) + } + } +} + +func filterDNSRecordsByPeers(records []nbdns.SimpleRecord, peers map[string]*nbpeer.Peer) []nbdns.SimpleRecord { + if len(records) == 0 || len(peers) == 0 { + return nil + } + + peerIPs := make(map[string]struct{}, len(peers)) + for _, peer := range peers { + if peer != nil { + peerIPs[peer.IP.String()] = struct{}{} + } + } + + filteredRecords := make([]nbdns.SimpleRecord, 0, len(records)) + for _, record := range records { + if _, exists := peerIPs[record.RData]; exists { + filteredRecords = append(filteredRecords, record) + } + } + + return filteredRecords +} + +func filterGroupIDToUserIDs(fullMap map[string][]string, neededGroupIDs map[string]struct{}) map[string][]string { + if len(neededGroupIDs) == 0 { + return nil + } + + filtered := make(map[string][]string, len(neededGroupIDs)) + for groupID := range neededGroupIDs { + if users, ok := fullMap[groupID]; ok { + filtered[groupID] = users + } + } + return filtered +} diff --git a/management/server/types/network.go b/management/server/types/network.go index d3708d80a..0d13de10f 100644 --- a/management/server/types/network.go +++ b/management/server/types/network.go @@ -152,6 +152,8 @@ func (n *Network) CurrentSerial() uint64 { } func (n *Network) Copy() *Network { + n.Mu.Lock() + defer n.Mu.Unlock() return &Network{ Identifier: n.Identifier, Net: n.Net, diff --git a/management/server/types/networkmap_comparison_test.go b/management/server/types/networkmap_comparison_test.go new file mode 100644 index 000000000..c5844cca0 --- /dev/null +++ b/management/server/types/networkmap_comparison_test.go @@ -0,0 +1,592 @@ +package types + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/netip" + "os" + "path/filepath" + "sort" + "testing" + "time" + + "github.com/stretchr/testify/require" + + nbdns "github.com/netbirdio/netbird/dns" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" + networkTypes "github.com/netbirdio/netbird/management/server/networks/types" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/posture" + "github.com/netbirdio/netbird/route" +) + +func TestNetworkMapComponents_CompareWithLegacy(t *testing.T) { + account := createTestAccount() + ctx := context.Background() + + peerID := testingPeerID + validatedPeersMap := make(map[string]struct{}) + for i := range numPeers { + pid := fmt.Sprintf("peer-%d", i) + if pid == offlinePeerID { + continue + } + validatedPeersMap[pid] = struct{}{} + } + + peersCustomZone := nbdns.CustomZone{} + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + + legacyNetworkMap := account.GetPeerNetworkMap( + ctx, + peerID, + peersCustomZone, + nil, + validatedPeersMap, + resourcePolicies, + routers, + nil, + groupIDToUserIDs, + ) + + components := account.GetPeerNetworkMapComponents( + ctx, + peerID, + peersCustomZone, + nil, + validatedPeersMap, + resourcePolicies, + routers, + groupIDToUserIDs, + ) + + if components == nil { + t.Fatal("GetPeerNetworkMapComponents returned nil") + } + + newNetworkMap := CalculateNetworkMapFromComponents(ctx, components) + + if newNetworkMap == nil { + t.Fatal("CalculateNetworkMapFromComponents returned nil") + } + + compareNetworkMaps(t, legacyNetworkMap, newNetworkMap) +} + +func TestNetworkMapComponents_GoldenFileComparison(t *testing.T) { + account := createTestAccount() + ctx := context.Background() + + peerID := testingPeerID + validatedPeersMap := make(map[string]struct{}) + for i := range numPeers { + pid := fmt.Sprintf("peer-%d", i) + if pid == offlinePeerID { + continue + } + validatedPeersMap[pid] = struct{}{} + } + + peersCustomZone := nbdns.CustomZone{} + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + + legacyNetworkMap := account.GetPeerNetworkMap( + ctx, + peerID, + peersCustomZone, + nil, + validatedPeersMap, + resourcePolicies, + routers, + nil, + groupIDToUserIDs, + ) + + components := account.GetPeerNetworkMapComponents( + ctx, + peerID, + peersCustomZone, + nil, + validatedPeersMap, + resourcePolicies, + routers, + groupIDToUserIDs, + ) + + require.NotNil(t, components, "GetPeerNetworkMapComponents returned nil") + + newNetworkMap := CalculateNetworkMapFromComponents(ctx, components) + require.NotNil(t, newNetworkMap, "CalculateNetworkMapFromComponents returned nil") + + normalizeAndSortNetworkMap(legacyNetworkMap) + normalizeAndSortNetworkMap(newNetworkMap) + + componentsJSON, err := json.MarshalIndent(components, "", " ") + require.NoError(t, err, "error marshaling components to JSON") + + legacyJSON, err := json.MarshalIndent(legacyNetworkMap, "", " ") + require.NoError(t, err, "error marshaling legacy network map to JSON") + + newJSON, err := json.MarshalIndent(newNetworkMap, "", " ") + require.NoError(t, err, "error marshaling new network map to JSON") + + goldenDir := filepath.Join("testdata", "comparison") + err = os.MkdirAll(goldenDir, 0755) + require.NoError(t, err) + + legacyGoldenPath := filepath.Join(goldenDir, "legacy_networkmap.json") + err = os.WriteFile(legacyGoldenPath, legacyJSON, 0644) + require.NoError(t, err, "error writing legacy golden file") + + newGoldenPath := filepath.Join(goldenDir, "components_networkmap.json") + err = os.WriteFile(newGoldenPath, newJSON, 0644) + require.NoError(t, err, "error writing components golden file") + + componentsPath := filepath.Join(goldenDir, "components.json") + err = os.WriteFile(componentsPath, componentsJSON, 0644) + require.NoError(t, err, "error writing components golden file") + + require.JSONEq(t, string(legacyJSON), string(newJSON), + "NetworkMaps from legacy and components approaches do not match.\n"+ + "Legacy JSON saved to: %s\n"+ + "Components JSON saved to: %s", + legacyGoldenPath, newGoldenPath) + + t.Logf("✅ NetworkMaps are identical") + t.Logf(" Legacy NetworkMap: %s", legacyGoldenPath) + t.Logf(" Components NetworkMap: %s", newGoldenPath) +} + +func normalizeAndSortNetworkMap(nm *NetworkMap) { + if nm == nil { + return + } + + sort.Slice(nm.Peers, func(i, j int) bool { + return nm.Peers[i].ID < nm.Peers[j].ID + }) + + sort.Slice(nm.OfflinePeers, func(i, j int) bool { + return nm.OfflinePeers[i].ID < nm.OfflinePeers[j].ID + }) + + sort.Slice(nm.Routes, func(i, j int) bool { + return string(nm.Routes[i].ID) < string(nm.Routes[j].ID) + }) + + sort.Slice(nm.FirewallRules, func(i, j int) bool { + if nm.FirewallRules[i].PeerIP != nm.FirewallRules[j].PeerIP { + return nm.FirewallRules[i].PeerIP < nm.FirewallRules[j].PeerIP + } + if nm.FirewallRules[i].Direction != nm.FirewallRules[j].Direction { + return nm.FirewallRules[i].Direction < nm.FirewallRules[j].Direction + } + if nm.FirewallRules[i].Protocol != nm.FirewallRules[j].Protocol { + return nm.FirewallRules[i].Protocol < nm.FirewallRules[j].Protocol + } + if nm.FirewallRules[i].Port != nm.FirewallRules[j].Port { + return nm.FirewallRules[i].Port < nm.FirewallRules[j].Port + } + return nm.FirewallRules[i].PolicyID < nm.FirewallRules[j].PolicyID + }) + + for i := range nm.RoutesFirewallRules { + sort.Strings(nm.RoutesFirewallRules[i].SourceRanges) + } + + sort.Slice(nm.RoutesFirewallRules, func(i, j int) bool { + if nm.RoutesFirewallRules[i].Destination != nm.RoutesFirewallRules[j].Destination { + return nm.RoutesFirewallRules[i].Destination < nm.RoutesFirewallRules[j].Destination + } + + minLen := len(nm.RoutesFirewallRules[i].SourceRanges) + if len(nm.RoutesFirewallRules[j].SourceRanges) < minLen { + minLen = len(nm.RoutesFirewallRules[j].SourceRanges) + } + for k := 0; k < minLen; k++ { + if nm.RoutesFirewallRules[i].SourceRanges[k] != nm.RoutesFirewallRules[j].SourceRanges[k] { + return nm.RoutesFirewallRules[i].SourceRanges[k] < nm.RoutesFirewallRules[j].SourceRanges[k] + } + } + if len(nm.RoutesFirewallRules[i].SourceRanges) != len(nm.RoutesFirewallRules[j].SourceRanges) { + return len(nm.RoutesFirewallRules[i].SourceRanges) < len(nm.RoutesFirewallRules[j].SourceRanges) + } + + if string(nm.RoutesFirewallRules[i].RouteID) != string(nm.RoutesFirewallRules[j].RouteID) { + return string(nm.RoutesFirewallRules[i].RouteID) < string(nm.RoutesFirewallRules[j].RouteID) + } + + if nm.RoutesFirewallRules[i].PolicyID != nm.RoutesFirewallRules[j].PolicyID { + return nm.RoutesFirewallRules[i].PolicyID < nm.RoutesFirewallRules[j].PolicyID + } + + if nm.RoutesFirewallRules[i].Port != nm.RoutesFirewallRules[j].Port { + return nm.RoutesFirewallRules[i].Port < nm.RoutesFirewallRules[j].Port + } + + return nm.RoutesFirewallRules[i].Protocol < nm.RoutesFirewallRules[j].Protocol + }) + + if nm.DNSConfig.CustomZones != nil { + for i := range nm.DNSConfig.CustomZones { + sort.Slice(nm.DNSConfig.CustomZones[i].Records, func(a, b int) bool { + return nm.DNSConfig.CustomZones[i].Records[a].Name < nm.DNSConfig.CustomZones[i].Records[b].Name + }) + } + } + + if len(nm.DNSConfig.NameServerGroups) != 0 { + sort.Slice(nm.DNSConfig.NameServerGroups, func(a, b int) bool { + return nm.DNSConfig.NameServerGroups[a].Name < nm.DNSConfig.NameServerGroups[b].Name + }) + } +} + +func compareNetworkMaps(t *testing.T, legacy, current *NetworkMap) { + t.Helper() + + if legacy.Network.Serial != current.Network.Serial { + t.Errorf("Network Serial mismatch: legacy=%d, current=%d", legacy.Network.Serial, current.Network.Serial) + } + + if len(legacy.Peers) != len(current.Peers) { + t.Errorf("Peers count mismatch: legacy=%d, current=%d", len(legacy.Peers), len(current.Peers)) + } + + legacyPeerIDs := make(map[string]bool) + for _, p := range legacy.Peers { + legacyPeerIDs[p.ID] = true + } + + for _, p := range current.Peers { + if !legacyPeerIDs[p.ID] { + t.Errorf("Current NetworkMap contains peer %s not in legacy", p.ID) + } + } + + if len(legacy.OfflinePeers) != len(current.OfflinePeers) { + t.Errorf("OfflinePeers count mismatch: legacy=%d, current=%d", len(legacy.OfflinePeers), len(current.OfflinePeers)) + } + + if len(legacy.FirewallRules) != len(current.FirewallRules) { + t.Logf("FirewallRules count mismatch: legacy=%d, current=%d", len(legacy.FirewallRules), len(current.FirewallRules)) + } + + if len(legacy.Routes) != len(current.Routes) { + t.Logf("Routes count mismatch: legacy=%d, current=%d", len(legacy.Routes), len(current.Routes)) + } + + if len(legacy.RoutesFirewallRules) != len(current.RoutesFirewallRules) { + t.Logf("RoutesFirewallRules count mismatch: legacy=%d, current=%d", len(legacy.RoutesFirewallRules), len(current.RoutesFirewallRules)) + } + + if legacy.DNSConfig.ServiceEnable != current.DNSConfig.ServiceEnable { + t.Errorf("DNSConfig.ServiceEnable mismatch: legacy=%v, current=%v", legacy.DNSConfig.ServiceEnable, current.DNSConfig.ServiceEnable) + } +} + +const ( + numPeers = 100 + devGroupID = "group-dev" + opsGroupID = "group-ops" + allGroupID = "group-all" + routeID = route.ID("route-main") + routeHA1ID = route.ID("route-ha-1") + routeHA2ID = route.ID("route-ha-2") + policyIDDevOps = "policy-dev-ops" + policyIDAll = "policy-all" + policyIDPosture = "policy-posture" + policyIDDrop = "policy-drop" + postureCheckID = "posture-check-ver" + networkResourceID = "res-database" + networkID = "net-database" + networkRouterID = "router-database" + nameserverGroupID = "ns-group-main" + testingPeerID = "peer-60" + expiredPeerID = "peer-98" + offlinePeerID = "peer-99" + routingPeerID = "peer-95" + testAccountID = "account-comparison-test" +) + +func createTestAccount() *Account { + peers := make(map[string]*nbpeer.Peer) + devGroupPeers, opsGroupPeers, allGroupPeers := []string{}, []string{}, []string{} + + for i := range numPeers { + peerID := fmt.Sprintf("peer-%d", i) + ip := net.IP{100, 64, 0, byte(i + 1)} + wtVersion := "0.25.0" + if i%2 == 0 { + wtVersion = "0.40.0" + } + + p := &nbpeer.Peer{ + ID: peerID, IP: ip, Key: fmt.Sprintf("key-%s", peerID), DNSLabel: fmt.Sprintf("peer%d", i+1), + Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()}, + UserID: "user-admin", Meta: nbpeer.PeerSystemMeta{WtVersion: wtVersion, GoOS: "linux"}, + } + + if peerID == expiredPeerID { + p.LoginExpirationEnabled = true + pastTimestamp := time.Now().Add(-2 * time.Hour) + p.LastLogin = &pastTimestamp + } + + peers[peerID] = p + allGroupPeers = append(allGroupPeers, peerID) + if i < numPeers/2 { + devGroupPeers = append(devGroupPeers, peerID) + } else { + opsGroupPeers = append(opsGroupPeers, peerID) + } + } + + groups := map[string]*Group{ + allGroupID: {ID: allGroupID, Name: "All", Peers: allGroupPeers}, + devGroupID: {ID: devGroupID, Name: "Developers", Peers: devGroupPeers}, + opsGroupID: {ID: opsGroupID, Name: "Operations", Peers: opsGroupPeers}, + } + + policies := []*Policy{ + { + ID: policyIDAll, Name: "Default-Allow", Enabled: true, + Rules: []*PolicyRule{{ + ID: policyIDAll, Name: "Allow All", Enabled: true, Action: PolicyTrafficActionAccept, + Protocol: PolicyRuleProtocolALL, Bidirectional: true, + Sources: []string{allGroupID}, Destinations: []string{allGroupID}, + }}, + }, + { + ID: policyIDDevOps, Name: "Dev to Ops Web Access", Enabled: true, + Rules: []*PolicyRule{{ + ID: policyIDDevOps, Name: "Dev -> Ops (HTTP Range)", Enabled: true, Action: PolicyTrafficActionAccept, + Protocol: PolicyRuleProtocolTCP, Bidirectional: false, + PortRanges: []RulePortRange{{Start: 8080, End: 8090}}, + Sources: []string{devGroupID}, Destinations: []string{opsGroupID}, + }}, + }, + { + ID: policyIDDrop, Name: "Drop DB traffic", Enabled: true, + Rules: []*PolicyRule{{ + ID: policyIDDrop, Name: "Drop DB", Enabled: true, Action: PolicyTrafficActionDrop, + Protocol: PolicyRuleProtocolTCP, Ports: []string{"5432"}, Bidirectional: true, + Sources: []string{devGroupID}, Destinations: []string{opsGroupID}, + }}, + }, + { + ID: policyIDPosture, Name: "Posture Check for DB Resource", Enabled: true, + SourcePostureChecks: []string{postureCheckID}, + Rules: []*PolicyRule{{ + ID: policyIDPosture, Name: "Allow DB Access", Enabled: true, Action: PolicyTrafficActionAccept, + Protocol: PolicyRuleProtocolALL, Bidirectional: true, + Sources: []string{opsGroupID}, DestinationResource: Resource{ID: networkResourceID}, + }}, + }, + } + + routes := map[route.ID]*route.Route{ + routeID: { + ID: routeID, Network: netip.MustParsePrefix("192.168.10.0/24"), + Peer: peers["peer-75"].Key, + PeerID: "peer-75", + Description: "Route to internal resource", Enabled: true, + PeerGroups: []string{devGroupID, opsGroupID}, + Groups: []string{devGroupID, opsGroupID}, + AccessControlGroups: []string{devGroupID}, + }, + routeHA1ID: { + ID: routeHA1ID, Network: netip.MustParsePrefix("10.10.0.0/16"), + Peer: peers["peer-80"].Key, + PeerID: "peer-80", + Description: "HA Route 1", Enabled: true, Metric: 1000, + PeerGroups: []string{allGroupID}, + Groups: []string{allGroupID}, + AccessControlGroups: []string{allGroupID}, + }, + routeHA2ID: { + ID: routeHA2ID, Network: netip.MustParsePrefix("10.10.0.0/16"), + Peer: peers["peer-90"].Key, + PeerID: "peer-90", + Description: "HA Route 2", Enabled: true, Metric: 900, + PeerGroups: []string{devGroupID, opsGroupID}, + Groups: []string{devGroupID, opsGroupID}, + AccessControlGroups: []string{allGroupID}, + }, + } + + account := &Account{ + Id: testAccountID, Peers: peers, Groups: groups, Policies: policies, Routes: routes, + Network: &Network{ + Identifier: "net-comparison-test", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(16, 32)}, Serial: 1, + }, + DNSSettings: DNSSettings{DisabledManagementGroups: []string{opsGroupID}}, + NameServerGroups: map[string]*nbdns.NameServerGroup{ + nameserverGroupID: { + ID: nameserverGroupID, Name: "Main NS", Enabled: true, Groups: []string{devGroupID}, + NameServers: []nbdns.NameServer{{IP: netip.MustParseAddr("8.8.8.8"), NSType: nbdns.UDPNameServerType, Port: 53}}, + }, + }, + PostureChecks: []*posture.Checks{ + {ID: postureCheckID, Name: "Check version", Checks: posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.26.0"}, + }}, + }, + NetworkResources: []*resourceTypes.NetworkResource{ + {ID: networkResourceID, NetworkID: networkID, AccountID: testAccountID, Enabled: true, Address: "db.netbird.cloud"}, + }, + Networks: []*networkTypes.Network{{ID: networkID, Name: "DB Network", AccountID: testAccountID}}, + NetworkRouters: []*routerTypes.NetworkRouter{ + {ID: networkRouterID, NetworkID: networkID, Peer: routingPeerID, Enabled: true, AccountID: testAccountID}, + }, + Settings: &Settings{PeerLoginExpirationEnabled: true, PeerLoginExpiration: 1 * time.Hour}, + } + + for _, p := range account.Policies { + p.AccountID = account.Id + } + for _, r := range account.Routes { + r.AccountID = account.Id + } + + return account +} + +func BenchmarkLegacyNetworkMap(b *testing.B) { + account := createTestAccount() + ctx := context.Background() + peerID := testingPeerID + validatedPeersMap := make(map[string]struct{}) + for i := range numPeers { + pid := fmt.Sprintf("peer-%d", i) + if pid != offlinePeerID { + validatedPeersMap[pid] = struct{}{} + } + } + + peersCustomZone := nbdns.CustomZone{} + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = account.GetPeerNetworkMap( + ctx, + peerID, + peersCustomZone, + nil, + validatedPeersMap, + resourcePolicies, + routers, + nil, + groupIDToUserIDs, + ) + } +} + +func BenchmarkComponentsNetworkMap(b *testing.B) { + account := createTestAccount() + ctx := context.Background() + peerID := testingPeerID + validatedPeersMap := make(map[string]struct{}) + for i := range numPeers { + pid := fmt.Sprintf("peer-%d", i) + if pid != offlinePeerID { + validatedPeersMap[pid] = struct{}{} + } + } + + peersCustomZone := nbdns.CustomZone{} + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + components := account.GetPeerNetworkMapComponents( + ctx, + peerID, + peersCustomZone, + nil, + validatedPeersMap, + resourcePolicies, + routers, + groupIDToUserIDs, + ) + _ = CalculateNetworkMapFromComponents(ctx, components) + } +} + +func BenchmarkComponentsCreation(b *testing.B) { + account := createTestAccount() + ctx := context.Background() + peerID := testingPeerID + validatedPeersMap := make(map[string]struct{}) + for i := range numPeers { + pid := fmt.Sprintf("peer-%d", i) + if pid != offlinePeerID { + validatedPeersMap[pid] = struct{}{} + } + } + + peersCustomZone := nbdns.CustomZone{} + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = account.GetPeerNetworkMapComponents( + ctx, + peerID, + peersCustomZone, + nil, + validatedPeersMap, + resourcePolicies, + routers, + groupIDToUserIDs, + ) + } +} + +func BenchmarkCalculationFromComponents(b *testing.B) { + account := createTestAccount() + ctx := context.Background() + peerID := testingPeerID + validatedPeersMap := make(map[string]struct{}) + for i := range numPeers { + pid := fmt.Sprintf("peer-%d", i) + if pid != offlinePeerID { + validatedPeersMap[pid] = struct{}{} + } + } + + peersCustomZone := nbdns.CustomZone{} + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + + components := account.GetPeerNetworkMapComponents( + ctx, + peerID, + peersCustomZone, + nil, + validatedPeersMap, + resourcePolicies, + routers, + groupIDToUserIDs, + ) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = CalculateNetworkMapFromComponents(ctx, components) + } +} diff --git a/management/server/types/networkmap_components.go b/management/server/types/networkmap_components.go new file mode 100644 index 000000000..23d84a994 --- /dev/null +++ b/management/server/types/networkmap_components.go @@ -0,0 +1,901 @@ +package types + +import ( + "context" + "maps" + "net" + "net/netip" + "slices" + "strconv" + "strings" + "time" + + "github.com/netbirdio/netbird/client/ssh/auth" + nbdns "github.com/netbirdio/netbird/dns" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/domain" +) + +const EnvNewNetworkMapCompacted = "NB_NETWORK_MAP_COMPACTED" + +type NetworkMapComponents struct { + PeerID string + + Network *Network + AccountSettings *AccountSettingsInfo + DNSSettings *DNSSettings + CustomZoneDomain string + + Peers map[string]*nbpeer.Peer + Groups map[string]*Group + Policies []*Policy + Routes []*route.Route + NameServerGroups []*nbdns.NameServerGroup + AllDNSRecords []nbdns.SimpleRecord + AccountZones []nbdns.CustomZone + ResourcePoliciesMap map[string][]*Policy + RoutersMap map[string]map[string]*routerTypes.NetworkRouter + NetworkResources []*resourceTypes.NetworkResource + + GroupIDToUserIDs map[string][]string + AllowedUserIDs map[string]struct{} + PostureFailedPeers map[string]map[string]struct{} + + RouterPeers map[string]*nbpeer.Peer +} + +type AccountSettingsInfo struct { + PeerLoginExpirationEnabled bool + PeerLoginExpiration time.Duration + PeerInactivityExpirationEnabled bool + PeerInactivityExpiration time.Duration +} + +func (c *NetworkMapComponents) GetPeerInfo(peerID string) *nbpeer.Peer { + return c.Peers[peerID] +} + +func (c *NetworkMapComponents) GetRouterPeerInfo(peerID string) *nbpeer.Peer { + return c.RouterPeers[peerID] +} + +func (c *NetworkMapComponents) GetGroupInfo(groupID string) *Group { + return c.Groups[groupID] +} + +func (c *NetworkMapComponents) IsPeerInGroup(peerID, groupID string) bool { + group := c.GetGroupInfo(groupID) + if group == nil { + return false + } + + return slices.Contains(group.Peers, peerID) +} + +func (c *NetworkMapComponents) GetPeerGroups(peerID string) map[string]struct{} { + groups := make(map[string]struct{}) + for groupID, group := range c.Groups { + if slices.Contains(group.Peers, peerID) { + groups[groupID] = struct{}{} + } + } + return groups +} + +func (c *NetworkMapComponents) ValidatePostureChecksOnPeer(peerID string, postureCheckIDs []string) bool { + _, exists := c.Peers[peerID] + if !exists { + return false + } + if len(postureCheckIDs) == 0 { + return true + } + for _, checkID := range postureCheckIDs { + if failedPeers, exists := c.PostureFailedPeers[checkID]; exists { + if _, failed := failedPeers[peerID]; failed { + return false + } + } + } + return true +} + +func CalculateNetworkMapFromComponents(ctx context.Context, components *NetworkMapComponents) *NetworkMap { + return components.Calculate(ctx) +} + +func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap { + targetPeerID := c.PeerID + + peerGroups := c.GetPeerGroups(targetPeerID) + + aclPeers, firewallRules, authorizedUsers, sshEnabled := c.getPeerConnectionResources(targetPeerID) + + peersToConnect, expiredPeers := c.filterPeersByLoginExpiration(aclPeers) + + routesUpdate := c.getRoutesToSync(targetPeerID, peersToConnect, peerGroups) + routesFirewallRules := c.getPeerRoutesFirewallRules(ctx, targetPeerID) + + isRouter, networkResourcesRoutes, sourcePeers := c.getNetworkResourcesRoutesToSync(targetPeerID) + var networkResourcesFirewallRules []*RouteFirewallRule + if isRouter { + networkResourcesFirewallRules = c.getPeerNetworkResourceFirewallRules(ctx, targetPeerID, networkResourcesRoutes) + } + + peersToConnectIncludingRouters := c.addNetworksRoutingPeers( + networkResourcesRoutes, + targetPeerID, + peersToConnect, + expiredPeers, + isRouter, + sourcePeers, + ) + + dnsManagementStatus := c.getPeerDNSManagementStatusFromGroups(peerGroups) + dnsUpdate := nbdns.Config{ + ServiceEnable: dnsManagementStatus, + } + + if dnsManagementStatus { + var customZones []nbdns.CustomZone + + if c.CustomZoneDomain != "" && len(c.AllDNSRecords) > 0 { + customZones = append(customZones, nbdns.CustomZone{ + Domain: c.CustomZoneDomain, + Records: c.AllDNSRecords, + }) + } + + customZones = append(customZones, c.AccountZones...) + + dnsUpdate.CustomZones = customZones + dnsUpdate.NameServerGroups = c.getPeerNSGroupsFromGroups(targetPeerID, peerGroups) + } + + return &NetworkMap{ + Peers: peersToConnectIncludingRouters, + Network: c.Network.Copy(), + Routes: append(networkResourcesRoutes, routesUpdate...), + DNSConfig: dnsUpdate, + OfflinePeers: expiredPeers, + FirewallRules: firewallRules, + RoutesFirewallRules: append(networkResourcesFirewallRules, routesFirewallRules...), + AuthorizedUsers: authorizedUsers, + EnableSSH: sshEnabled, + } +} + +func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) ([]*nbpeer.Peer, []*FirewallRule, map[string]map[string]struct{}, bool) { + targetPeer := c.GetPeerInfo(targetPeerID) + if targetPeer == nil { + return nil, nil, nil, false + } + + generateResources, getAccumulatedResources := c.connResourcesGenerator(targetPeer) + authorizedUsers := make(map[string]map[string]struct{}) + sshEnabled := false + + for _, policy := range c.Policies { + if !policy.Enabled { + continue + } + + for _, rule := range policy.Rules { + if !rule.Enabled { + continue + } + + var sourcePeers, destinationPeers []*nbpeer.Peer + var peerInSources, peerInDestinations bool + + if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" { + sourcePeers, peerInSources = c.getPeerFromResource(rule.SourceResource, targetPeerID) + } else { + sourcePeers, peerInSources = c.getAllPeersFromGroups(rule.Sources, targetPeerID, policy.SourcePostureChecks) + } + + if rule.DestinationResource.Type == ResourceTypePeer && rule.DestinationResource.ID != "" { + destinationPeers, peerInDestinations = c.getPeerFromResource(rule.DestinationResource, targetPeerID) + } else { + destinationPeers, peerInDestinations = c.getAllPeersFromGroups(rule.Destinations, targetPeerID, nil) + } + + if rule.Bidirectional { + if peerInSources { + generateResources(rule, destinationPeers, FirewallRuleDirectionIN) + } + if peerInDestinations { + generateResources(rule, sourcePeers, FirewallRuleDirectionOUT) + } + } + + if peerInSources { + generateResources(rule, destinationPeers, FirewallRuleDirectionOUT) + } + + if peerInDestinations { + generateResources(rule, sourcePeers, FirewallRuleDirectionIN) + } + + if peerInDestinations && rule.Protocol == PolicyRuleProtocolNetbirdSSH { + sshEnabled = true + switch { + case len(rule.AuthorizedGroups) > 0: + for groupID, localUsers := range rule.AuthorizedGroups { + userIDs, ok := c.GroupIDToUserIDs[groupID] + if !ok { + continue + } + + if len(localUsers) == 0 { + localUsers = []string{auth.Wildcard} + } + + for _, localUser := range localUsers { + if authorizedUsers[localUser] == nil { + authorizedUsers[localUser] = make(map[string]struct{}) + } + for _, userID := range userIDs { + authorizedUsers[localUser][userID] = struct{}{} + } + } + } + case rule.AuthorizedUser != "": + if authorizedUsers[auth.Wildcard] == nil { + authorizedUsers[auth.Wildcard] = make(map[string]struct{}) + } + authorizedUsers[auth.Wildcard][rule.AuthorizedUser] = struct{}{} + default: + authorizedUsers[auth.Wildcard] = c.getAllowedUserIDs() + } + } else if peerInDestinations && policyRuleImpliesLegacySSH(rule) && targetPeer.SSHEnabled { + sshEnabled = true + authorizedUsers[auth.Wildcard] = c.getAllowedUserIDs() + } + } + } + + peers, fwRules := getAccumulatedResources() + return peers, fwRules, authorizedUsers, sshEnabled +} + +func (c *NetworkMapComponents) getAllowedUserIDs() map[string]struct{} { + if c.AllowedUserIDs != nil { + result := make(map[string]struct{}, len(c.AllowedUserIDs)) + maps.Copy(result, c.AllowedUserIDs) + return result + } + return make(map[string]struct{}) +} + +func (c *NetworkMapComponents) connResourcesGenerator(targetPeer *nbpeer.Peer) (func(*PolicyRule, []*nbpeer.Peer, int), func() ([]*nbpeer.Peer, []*FirewallRule)) { + rulesExists := make(map[string]struct{}) + peersExists := make(map[string]struct{}) + rules := make([]*FirewallRule, 0) + peers := make([]*nbpeer.Peer, 0) + + return func(rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int) { + protocol := rule.Protocol + if protocol == PolicyRuleProtocolNetbirdSSH { + protocol = PolicyRuleProtocolTCP + } + + protocolStr := string(protocol) + actionStr := string(rule.Action) + dirStr := strconv.Itoa(direction) + portsJoined := strings.Join(rule.Ports, ",") + + for _, peer := range groupPeers { + if peer == nil { + continue + } + + if _, ok := peersExists[peer.ID]; !ok { + peers = append(peers, peer) + peersExists[peer.ID] = struct{}{} + } + + peerIP := net.IP(peer.IP).String() + + fr := FirewallRule{ + PolicyID: rule.ID, + PeerIP: peerIP, + Direction: direction, + Action: actionStr, + Protocol: protocolStr, + } + + ruleID := rule.ID + peerIP + dirStr + + protocolStr + actionStr + portsJoined + if _, ok := rulesExists[ruleID]; ok { + continue + } + rulesExists[ruleID] = struct{}{} + + if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 { + rules = append(rules, &fr) + continue + } + + rules = append(rules, expandPortsAndRanges(fr, rule, targetPeer)...) + } + }, func() ([]*nbpeer.Peer, []*FirewallRule) { + return peers, rules + } +} + +func (c *NetworkMapComponents) getAllPeersFromGroups(groups []string, peerID string, sourcePostureChecksIDs []string) ([]*nbpeer.Peer, bool) { + peerInGroups := false + uniquePeerIDs := c.getUniquePeerIDsFromGroupsIDs(groups) + filteredPeers := make([]*nbpeer.Peer, 0, len(uniquePeerIDs)) + + for _, p := range uniquePeerIDs { + peerInfo := c.GetPeerInfo(p) + if peerInfo == nil { + continue + } + + if _, ok := c.Peers[p]; !ok { + continue + } + + if !c.ValidatePostureChecksOnPeer(p, sourcePostureChecksIDs) { + continue + } + + if p == peerID { + peerInGroups = true + continue + } + + filteredPeers = append(filteredPeers, peerInfo) + } + + return filteredPeers, peerInGroups +} + +func (c *NetworkMapComponents) getUniquePeerIDsFromGroupsIDs(groups []string) []string { + peerIDs := make(map[string]struct{}, len(groups)) + for _, groupID := range groups { + group := c.GetGroupInfo(groupID) + if group == nil { + continue + } + + if group.IsGroupAll() || len(groups) == 1 { + return group.Peers + } + + for _, peerID := range group.Peers { + peerIDs[peerID] = struct{}{} + } + } + + ids := make([]string, 0, len(peerIDs)) + for peerID := range peerIDs { + ids = append(ids, peerID) + } + + return ids +} + +func (c *NetworkMapComponents) getPeerFromResource(resource Resource, peerID string) ([]*nbpeer.Peer, bool) { + if resource.ID == peerID { + return []*nbpeer.Peer{}, true + } + + peerInfo := c.GetPeerInfo(resource.ID) + if peerInfo == nil { + return []*nbpeer.Peer{}, false + } + + return []*nbpeer.Peer{peerInfo}, false +} + +func (c *NetworkMapComponents) filterPeersByLoginExpiration(aclPeers []*nbpeer.Peer) ([]*nbpeer.Peer, []*nbpeer.Peer) { + peersToConnect := make([]*nbpeer.Peer, 0, len(aclPeers)) + var expiredPeers []*nbpeer.Peer + + for _, p := range aclPeers { + expired, _ := p.LoginExpired(c.AccountSettings.PeerLoginExpiration) + if c.AccountSettings.PeerLoginExpirationEnabled && expired { + expiredPeers = append(expiredPeers, p) + continue + } + peersToConnect = append(peersToConnect, p) + } + + return peersToConnect, expiredPeers +} + +func (c *NetworkMapComponents) getPeerDNSManagementStatusFromGroups(peerGroups map[string]struct{}) bool { + for _, groupID := range c.DNSSettings.DisabledManagementGroups { + if _, found := peerGroups[groupID]; found { + return false + } + } + return true +} + +func (c *NetworkMapComponents) getPeerNSGroupsFromGroups(peerID string, groupList map[string]struct{}) []*nbdns.NameServerGroup { + var peerNSGroups []*nbdns.NameServerGroup + + targetPeerInfo := c.GetPeerInfo(peerID) + if targetPeerInfo == nil { + return peerNSGroups + } + + peerIPStr := targetPeerInfo.IP.String() + + for _, nsGroup := range c.NameServerGroups { + if !nsGroup.Enabled { + continue + } + for _, gID := range nsGroup.Groups { + if _, found := groupList[gID]; found { + if !c.peerIsNameserver(peerIPStr, nsGroup) { + peerNSGroups = append(peerNSGroups, nsGroup.Copy()) + } + break + } + } + } + + return peerNSGroups +} + +func (c *NetworkMapComponents) peerIsNameserver(peerIPStr string, nsGroup *nbdns.NameServerGroup) bool { + for _, ns := range nsGroup.NameServers { + if peerIPStr == ns.IP.String() { + return true + } + } + return false +} + +func (c *NetworkMapComponents) getRoutesToSync(peerID string, aclPeers []*nbpeer.Peer, peerGroups LookupMap) []*route.Route { + routes, peerDisabledRoutes := c.getRoutingPeerRoutes(peerID) + peerRoutesMembership := make(LookupMap) + for _, r := range append(routes, peerDisabledRoutes...) { + peerRoutesMembership[string(r.GetHAUniqueID())] = struct{}{} + } + + for _, peer := range aclPeers { + activeRoutes, _ := c.getRoutingPeerRoutes(peer.ID) + groupFilteredRoutes := c.filterRoutesByGroups(activeRoutes, peerGroups) + filteredRoutes := c.filterRoutesFromPeersOfSameHAGroup(groupFilteredRoutes, peerRoutesMembership) + routes = append(routes, filteredRoutes...) + } + + return routes +} + +func (c *NetworkMapComponents) getRoutingPeerRoutes(peerID string) (enabledRoutes []*route.Route, disabledRoutes []*route.Route) { + peerInfo := c.GetPeerInfo(peerID) + if peerInfo == nil { + peerInfo = c.GetRouterPeerInfo(peerID) + } + if peerInfo == nil { + return enabledRoutes, disabledRoutes + } + + seenRoute := make(map[route.ID]struct{}) + + takeRoute := func(r *route.Route) { + if _, ok := seenRoute[r.ID]; ok { + return + } + seenRoute[r.ID] = struct{}{} + + r.Peer = peerInfo.Key + + if r.Enabled { + enabledRoutes = append(enabledRoutes, r) + return + } + disabledRoutes = append(disabledRoutes, r) + } + + for _, r := range c.Routes { + for _, groupID := range r.PeerGroups { + group := c.GetGroupInfo(groupID) + if group == nil { + continue + } + for _, id := range group.Peers { + if id != peerID { + continue + } + + newPeerRoute := r.Copy() + newPeerRoute.Peer = id + newPeerRoute.PeerGroups = nil + newPeerRoute.ID = route.ID(string(r.ID) + ":" + id) + takeRoute(newPeerRoute) + break + } + } + if r.Peer == peerID { + takeRoute(r.Copy()) + } + } + + return enabledRoutes, disabledRoutes +} + + +func (c *NetworkMapComponents) filterRoutesByGroups(routes []*route.Route, groupListMap LookupMap) []*route.Route { + var filteredRoutes []*route.Route + for _, r := range routes { + for _, groupID := range r.Groups { + _, found := groupListMap[groupID] + if found { + filteredRoutes = append(filteredRoutes, r) + break + } + } + } + return filteredRoutes +} + +func (c *NetworkMapComponents) filterRoutesFromPeersOfSameHAGroup(routes []*route.Route, peerMemberships LookupMap) []*route.Route { + var filteredRoutes []*route.Route + for _, r := range routes { + _, found := peerMemberships[string(r.GetHAUniqueID())] + if !found { + filteredRoutes = append(filteredRoutes, r) + } + } + return filteredRoutes +} + +func (c *NetworkMapComponents) getPeerRoutesFirewallRules(ctx context.Context, peerID string) []*RouteFirewallRule { + routesFirewallRules := make([]*RouteFirewallRule, 0) + + enabledRoutes, _ := c.getRoutingPeerRoutes(peerID) + for _, r := range enabledRoutes { + if len(r.AccessControlGroups) == 0 { + defaultPermit := c.getDefaultPermit(r) + routesFirewallRules = append(routesFirewallRules, defaultPermit...) + continue + } + + distributionPeers := c.getDistributionGroupsPeers(r) + + for _, accessGroup := range r.AccessControlGroups { + policies := c.getAllRoutePoliciesFromGroups([]string{accessGroup}) + rules := c.getRouteFirewallRules(ctx, peerID, policies, r, distributionPeers) + routesFirewallRules = append(routesFirewallRules, rules...) + } + } + + return routesFirewallRules +} + +func (c *NetworkMapComponents) getDefaultPermit(r *route.Route) []*RouteFirewallRule { + var rules []*RouteFirewallRule + + sources := []string{"0.0.0.0/0"} + if r.Network.Addr().Is6() { + sources = []string{"::/0"} + } + + rule := RouteFirewallRule{ + SourceRanges: sources, + Action: string(PolicyTrafficActionAccept), + Destination: r.Network.String(), + Protocol: string(PolicyRuleProtocolALL), + Domains: r.Domains, + IsDynamic: r.IsDynamic(), + RouteID: r.ID, + } + + rules = append(rules, &rule) + + if r.IsDynamic() { + ruleV6 := rule + ruleV6.SourceRanges = []string{"::/0"} + rules = append(rules, &ruleV6) + } + + return rules +} + +func (c *NetworkMapComponents) getDistributionGroupsPeers(r *route.Route) map[string]struct{} { + distPeers := make(map[string]struct{}) + for _, id := range r.Groups { + group := c.GetGroupInfo(id) + if group == nil { + continue + } + + for _, pID := range group.Peers { + distPeers[pID] = struct{}{} + } + } + return distPeers +} + +func (c *NetworkMapComponents) getAllRoutePoliciesFromGroups(accessControlGroups []string) []*Policy { + routePolicies := make([]*Policy, 0) + for _, groupID := range accessControlGroups { + for _, policy := range c.Policies { + for _, rule := range policy.Rules { + if slices.Contains(rule.Destinations, groupID) { + routePolicies = append(routePolicies, policy) + } + } + } + } + + return routePolicies +} + +func (c *NetworkMapComponents) getRouteFirewallRules(ctx context.Context, peerID string, policies []*Policy, route *route.Route, distributionPeers map[string]struct{}) []*RouteFirewallRule { + var fwRules []*RouteFirewallRule + for _, policy := range policies { + if !policy.Enabled { + continue + } + + for _, rule := range policy.Rules { + if !rule.Enabled { + continue + } + + rulePeers := c.getRulePeers(rule, policy.SourcePostureChecks, peerID, distributionPeers) + rules := generateRouteFirewallRules(ctx, route, rule, rulePeers, FirewallRuleDirectionIN) + fwRules = append(fwRules, rules...) + } + } + return fwRules +} + +func (c *NetworkMapComponents) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}) []*nbpeer.Peer { + distPeersWithPolicy := make(map[string]struct{}) + for _, id := range rule.Sources { + group := c.GetGroupInfo(id) + if group == nil { + continue + } + + for _, pID := range group.Peers { + if pID == peerID { + continue + } + _, distPeer := distributionPeers[pID] + _, valid := c.Peers[pID] + if distPeer && valid && c.ValidatePostureChecksOnPeer(pID, postureChecks) { + distPeersWithPolicy[pID] = struct{}{} + } + } + } + if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" { + _, distPeer := distributionPeers[rule.SourceResource.ID] + _, valid := c.Peers[rule.SourceResource.ID] + if distPeer && valid && c.ValidatePostureChecksOnPeer(rule.SourceResource.ID, postureChecks) { + distPeersWithPolicy[rule.SourceResource.ID] = struct{}{} + } + } + + distributionGroupPeers := make([]*nbpeer.Peer, 0, len(distPeersWithPolicy)) + for pID := range distPeersWithPolicy { + peerInfo := c.GetPeerInfo(pID) + if peerInfo == nil { + continue + } + distributionGroupPeers = append(distributionGroupPeers, peerInfo) + } + return distributionGroupPeers +} + +func (c *NetworkMapComponents) getNetworkResourcesRoutesToSync(peerID string) (bool, []*route.Route, map[string]struct{}) { + var isRoutingPeer bool + var routes []*route.Route + allSourcePeers := make(map[string]struct{}) + + for _, resource := range c.NetworkResources { + if !resource.Enabled { + continue + } + + var addSourcePeers bool + + networkRoutingPeers, exists := c.RoutersMap[resource.NetworkID] + if exists { + if router, ok := networkRoutingPeers[peerID]; ok { + isRoutingPeer, addSourcePeers = true, true + routes = append(routes, c.getNetworkResourcesRoutes(resource, peerID, router)...) + } + } + + addedResourceRoute := false + for _, policy := range c.ResourcePoliciesMap[resource.ID] { + var peers []string + if policy.Rules[0].SourceResource.Type == ResourceTypePeer && policy.Rules[0].SourceResource.ID != "" { + peers = []string{policy.Rules[0].SourceResource.ID} + } else { + peers = c.getUniquePeerIDsFromGroupsIDs(policy.SourceGroups()) + } + if addSourcePeers { + for _, pID := range c.getPostureValidPeers(peers, policy.SourcePostureChecks) { + allSourcePeers[pID] = struct{}{} + } + } else if slices.Contains(peers, peerID) && c.ValidatePostureChecksOnPeer(peerID, policy.SourcePostureChecks) { + for peerId, router := range networkRoutingPeers { + routes = append(routes, c.getNetworkResourcesRoutes(resource, peerId, router)...) + } + addedResourceRoute = true + } + if addedResourceRoute { + break + } + } + } + + return isRoutingPeer, routes, allSourcePeers +} + +func (c *NetworkMapComponents) getNetworkResourcesRoutes(resource *resourceTypes.NetworkResource, peerID string, router *routerTypes.NetworkRouter) []*route.Route { + resourceAppliedPolicies := c.ResourcePoliciesMap[resource.ID] + + var routes []*route.Route + if len(resourceAppliedPolicies) > 0 { + peerInfo := c.GetPeerInfo(peerID) + if peerInfo != nil { + routes = append(routes, c.networkResourceToRoute(resource, peerInfo, router)) + } + } + + return routes +} + +func (c *NetworkMapComponents) networkResourceToRoute(resource *resourceTypes.NetworkResource, peer *nbpeer.Peer, router *routerTypes.NetworkRouter) *route.Route { + r := &route.Route{ + ID: route.ID(resource.ID + ":" + peer.ID), + AccountID: resource.AccountID, + Peer: peer.Key, + PeerID: peer.ID, + Metric: router.Metric, + Masquerade: router.Masquerade, + Enabled: resource.Enabled, + KeepRoute: true, + NetID: route.NetID(resource.Name), + Description: resource.Description, + } + + if resource.Type == resourceTypes.Host || resource.Type == resourceTypes.Subnet { + r.Network = resource.Prefix + + r.NetworkType = route.IPv4Network + if resource.Prefix.Addr().Is6() { + r.NetworkType = route.IPv6Network + } + } + + if resource.Type == resourceTypes.Domain { + domainList, err := domain.FromStringList([]string{resource.Domain}) + if err == nil { + r.Domains = domainList + r.NetworkType = route.DomainNetwork + r.Network = netip.PrefixFrom(netip.AddrFrom4([4]byte{192, 0, 2, 0}), 32) + } + } + + return r +} + +func (c *NetworkMapComponents) getPostureValidPeers(inputPeers []string, postureChecksIDs []string) []string { + var dest []string + for _, peerID := range inputPeers { + if c.ValidatePostureChecksOnPeer(peerID, postureChecksIDs) { + dest = append(dest, peerID) + } + } + return dest +} + +func (c *NetworkMapComponents) getPeerNetworkResourceFirewallRules(ctx context.Context, peerID string, routes []*route.Route) []*RouteFirewallRule { + routesFirewallRules := make([]*RouteFirewallRule, 0) + + peerInfo := c.GetPeerInfo(peerID) + if peerInfo == nil { + return routesFirewallRules + } + + for _, r := range routes { + if r.Peer != peerInfo.Key { + continue + } + + resourceID := string(r.GetResourceID()) + resourcePolicies := c.ResourcePoliciesMap[resourceID] + distributionPeers := c.getPoliciesSourcePeers(resourcePolicies) + + rules := c.getRouteFirewallRules(ctx, peerID, resourcePolicies, r, distributionPeers) + for _, rule := range rules { + if len(rule.SourceRanges) > 0 { + routesFirewallRules = append(routesFirewallRules, rule) + } + } + } + + return routesFirewallRules +} + +func (c *NetworkMapComponents) getPoliciesSourcePeers(policies []*Policy) map[string]struct{} { + sourcePeers := make(map[string]struct{}) + + for _, policy := range policies { + for _, rule := range policy.Rules { + for _, sourceGroup := range rule.Sources { + group := c.GetGroupInfo(sourceGroup) + if group == nil { + continue + } + + for _, peer := range group.Peers { + sourcePeers[peer] = struct{}{} + } + } + + if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" { + sourcePeers[rule.SourceResource.ID] = struct{}{} + } + } + } + + return sourcePeers +} + +func (c *NetworkMapComponents) addNetworksRoutingPeers( + networkResourcesRoutes []*route.Route, + peerID string, + peersToConnect []*nbpeer.Peer, + expiredPeers []*nbpeer.Peer, + isRouter bool, + sourcePeers map[string]struct{}, +) []*nbpeer.Peer { + + networkRoutesPeers := make(map[string]struct{}, len(networkResourcesRoutes)) + for _, r := range networkResourcesRoutes { + networkRoutesPeers[r.PeerID] = struct{}{} + } + + delete(sourcePeers, peerID) + delete(networkRoutesPeers, peerID) + + for _, existingPeer := range peersToConnect { + delete(sourcePeers, existingPeer.ID) + delete(networkRoutesPeers, existingPeer.ID) + } + for _, expPeer := range expiredPeers { + delete(sourcePeers, expPeer.ID) + delete(networkRoutesPeers, expPeer.ID) + } + + missingPeers := make(map[string]struct{}, len(sourcePeers)+len(networkRoutesPeers)) + if isRouter { + for p := range sourcePeers { + missingPeers[p] = struct{}{} + } + } + for p := range networkRoutesPeers { + missingPeers[p] = struct{}{} + } + + for p := range missingPeers { + peerInfo := c.GetPeerInfo(p) + if peerInfo == nil { + peerInfo = c.GetRouterPeerInfo(p) + } + if peerInfo != nil { + peersToConnect = append(peersToConnect, peerInfo) + } + } + + return peersToConnect +} diff --git a/management/server/types/networkmap_components_compact.go b/management/server/types/networkmap_components_compact.go new file mode 100644 index 000000000..b60f8bdb1 --- /dev/null +++ b/management/server/types/networkmap_components_compact.go @@ -0,0 +1,230 @@ +package types + +import ( + nbdns "github.com/netbirdio/netbird/dns" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/route" +) + +type GroupCompact struct { + Name string + PeerIndexes []int +} + +type NetworkMapComponentsCompact struct { + PeerID string + + Network *Network + AccountSettings *AccountSettingsInfo + DNSSettings *DNSSettings + CustomZoneDomain string + + AllPeers []*nbpeer.Peer + PeerIndexes []int + RouterPeerIndexes []int + + Groups map[string]*GroupCompact + AllPolicies []*Policy + PolicyIndexes []int + ResourcePoliciesMap map[string][]int + Routes []*route.Route + NameServerGroups []*nbdns.NameServerGroup + AllDNSRecords []nbdns.SimpleRecord + AccountZones []nbdns.CustomZone + + RoutersMap map[string]map[string]*routerTypes.NetworkRouter + NetworkResources []*resourceTypes.NetworkResource + + GroupIDToUserIDs map[string][]string + AllowedUserIDs map[string]struct{} + PostureFailedPeers map[string]map[string]struct{} +} + +func (c *NetworkMapComponents) ToCompact() *NetworkMapComponentsCompact { + peerToIndex := make(map[string]int) + var allPeers []*nbpeer.Peer + + for id, peer := range c.Peers { + if _, exists := peerToIndex[id]; !exists { + peerToIndex[id] = len(allPeers) + allPeers = append(allPeers, peer) + } + } + + for id, peer := range c.RouterPeers { + if _, exists := peerToIndex[id]; !exists { + peerToIndex[id] = len(allPeers) + allPeers = append(allPeers, peer) + } + } + + peerIndexes := make([]int, 0, len(c.Peers)) + for id := range c.Peers { + peerIndexes = append(peerIndexes, peerToIndex[id]) + } + + routerPeerIndexes := make([]int, 0, len(c.RouterPeers)) + for id := range c.RouterPeers { + routerPeerIndexes = append(routerPeerIndexes, peerToIndex[id]) + } + + groups := make(map[string]*GroupCompact, len(c.Groups)) + for id, group := range c.Groups { + peerIdxs := make([]int, 0, len(group.Peers)) + for _, peerID := range group.Peers { + if idx, ok := peerToIndex[peerID]; ok { + peerIdxs = append(peerIdxs, idx) + } + } + groups[id] = &GroupCompact{ + Name: group.Name, + PeerIndexes: peerIdxs, + } + } + + policyToIndex := make(map[*Policy]int) + var allPolicies []*Policy + + for _, policy := range c.Policies { + if _, exists := policyToIndex[policy]; !exists { + policyToIndex[policy] = len(allPolicies) + allPolicies = append(allPolicies, policy) + } + } + + for _, policies := range c.ResourcePoliciesMap { + for _, policy := range policies { + if _, exists := policyToIndex[policy]; !exists { + policyToIndex[policy] = len(allPolicies) + allPolicies = append(allPolicies, policy) + } + } + } + + policyIndexes := make([]int, len(c.Policies)) + for i, policy := range c.Policies { + policyIndexes[i] = policyToIndex[policy] + } + + var resourcePoliciesMap map[string][]int + if len(c.ResourcePoliciesMap) > 0 { + resourcePoliciesMap = make(map[string][]int, len(c.ResourcePoliciesMap)) + for resID, policies := range c.ResourcePoliciesMap { + indexes := make([]int, len(policies)) + for i, policy := range policies { + indexes[i] = policyToIndex[policy] + } + resourcePoliciesMap[resID] = indexes + } + } + + return &NetworkMapComponentsCompact{ + PeerID: c.PeerID, + Network: c.Network, + AccountSettings: c.AccountSettings, + DNSSettings: c.DNSSettings, + CustomZoneDomain: c.CustomZoneDomain, + + AllPeers: allPeers, + PeerIndexes: peerIndexes, + RouterPeerIndexes: routerPeerIndexes, + + Groups: groups, + AllPolicies: allPolicies, + PolicyIndexes: policyIndexes, + ResourcePoliciesMap: resourcePoliciesMap, + Routes: c.Routes, + NameServerGroups: c.NameServerGroups, + AllDNSRecords: c.AllDNSRecords, + AccountZones: c.AccountZones, + + RoutersMap: c.RoutersMap, + NetworkResources: c.NetworkResources, + + GroupIDToUserIDs: c.GroupIDToUserIDs, + AllowedUserIDs: c.AllowedUserIDs, + PostureFailedPeers: c.PostureFailedPeers, + } +} + +func (c *NetworkMapComponentsCompact) ToFull() *NetworkMapComponents { + peers := make(map[string]*nbpeer.Peer, len(c.PeerIndexes)) + for _, idx := range c.PeerIndexes { + if idx >= 0 && idx < len(c.AllPeers) { + peer := c.AllPeers[idx] + peers[peer.ID] = peer + } + } + + routerPeers := make(map[string]*nbpeer.Peer, len(c.RouterPeerIndexes)) + for _, idx := range c.RouterPeerIndexes { + if idx >= 0 && idx < len(c.AllPeers) { + peer := c.AllPeers[idx] + routerPeers[peer.ID] = peer + } + } + + groups := make(map[string]*Group, len(c.Groups)) + for id, gc := range c.Groups { + peerIDs := make([]string, 0, len(gc.PeerIndexes)) + for _, idx := range gc.PeerIndexes { + if idx >= 0 && idx < len(c.AllPeers) { + peerIDs = append(peerIDs, c.AllPeers[idx].ID) + } + } + groups[id] = &Group{ + ID: id, + Name: gc.Name, + Peers: peerIDs, + } + } + + policies := make([]*Policy, len(c.PolicyIndexes)) + for i, idx := range c.PolicyIndexes { + if idx >= 0 && idx < len(c.AllPolicies) { + policies[i] = c.AllPolicies[idx] + } + } + + var resourcePoliciesMap map[string][]*Policy + if len(c.ResourcePoliciesMap) > 0 { + resourcePoliciesMap = make(map[string][]*Policy, len(c.ResourcePoliciesMap)) + for resID, indexes := range c.ResourcePoliciesMap { + pols := make([]*Policy, 0, len(indexes)) + for _, idx := range indexes { + if idx >= 0 && idx < len(c.AllPolicies) { + pols = append(pols, c.AllPolicies[idx]) + } + } + resourcePoliciesMap[resID] = pols + } + } + + return &NetworkMapComponents{ + PeerID: c.PeerID, + Network: c.Network, + AccountSettings: c.AccountSettings, + DNSSettings: c.DNSSettings, + CustomZoneDomain: c.CustomZoneDomain, + + Peers: peers, + RouterPeers: routerPeers, + + Groups: groups, + Policies: policies, + Routes: c.Routes, + NameServerGroups: c.NameServerGroups, + AllDNSRecords: c.AllDNSRecords, + AccountZones: c.AccountZones, + + ResourcePoliciesMap: resourcePoliciesMap, + RoutersMap: c.RoutersMap, + NetworkResources: c.NetworkResources, + + GroupIDToUserIDs: c.GroupIDToUserIDs, + AllowedUserIDs: c.AllowedUserIDs, + PostureFailedPeers: c.PostureFailedPeers, + } +} diff --git a/management/server/types/networkmap_golden_test.go b/management/server/types/networkmap_golden_test.go index ef6c51779..53261f22d 100644 --- a/management/server/types/networkmap_golden_test.go +++ b/management/server/types/networkmap_golden_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/require" "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/internals/modules/zones" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" networkTypes "github.com/netbirdio/netbird/management/server/networks/types" @@ -70,7 +71,7 @@ func TestGetPeerNetworkMap_Golden(t *testing.T) { resourcePolicies := account.GetResourcePoliciesMap() routers := account.GetResourceRoutersMap() - legacyNetworkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, nil, validatedPeersMap, resourcePolicies, routers, nil, account.GetActiveGroupUsers()) + legacyNetworkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, []*zones.Zone{}, validatedPeersMap, resourcePolicies, routers, nil, account.GetActiveGroupUsers()) normalizeAndSortNetworkMap(legacyNetworkMap) legacyJSON, err := json.MarshalIndent(toNetworkMapJSON(legacyNetworkMap), "", " ") require.NoError(t, err, "error marshaling legacy network map to JSON") @@ -115,7 +116,7 @@ func BenchmarkGetPeerNetworkMap(b *testing.B) { b.Run("old builder", func(b *testing.B) { for range b.N { for _, peerID := range peerIDs { - _ = account.GetPeerNetworkMap(ctx, peerID, dns.CustomZone{}, nil, validatedPeersMap, nil, nil, nil, account.GetActiveGroupUsers()) + _ = account.GetPeerNetworkMap(ctx, peerID, dns.CustomZone{}, []*zones.Zone{}, validatedPeersMap, nil, nil, nil, account.GetActiveGroupUsers()) } } }) @@ -177,7 +178,7 @@ func TestGetPeerNetworkMap_Golden_WithNewPeer(t *testing.T) { resourcePolicies := account.GetResourcePoliciesMap() routers := account.GetResourceRoutersMap() - legacyNetworkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, nil, validatedPeersMap, resourcePolicies, routers, nil, account.GetActiveGroupUsers()) + legacyNetworkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, []*zones.Zone{}, validatedPeersMap, resourcePolicies, routers, nil, account.GetActiveGroupUsers()) normalizeAndSortNetworkMap(legacyNetworkMap) legacyJSON, err := json.MarshalIndent(toNetworkMapJSON(legacyNetworkMap), "", " ") require.NoError(t, err, "error marshaling legacy network map to JSON") @@ -240,7 +241,7 @@ func BenchmarkGetPeerNetworkMap_AfterPeerAdded(b *testing.B) { b.Run("old builder after add", func(b *testing.B) { for i := 0; i < b.N; i++ { for _, testingPeerID := range peerIDs { - _ = account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, nil, validatedPeersMap, nil, nil, nil, account.GetActiveGroupUsers()) + _ = account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, []*zones.Zone{}, validatedPeersMap, nil, nil, nil, account.GetActiveGroupUsers()) } } }) @@ -317,7 +318,7 @@ func TestGetPeerNetworkMap_Golden_WithNewRoutingPeer(t *testing.T) { resourcePolicies := account.GetResourcePoliciesMap() routers := account.GetResourceRoutersMap() - legacyNetworkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, nil, validatedPeersMap, resourcePolicies, routers, nil, account.GetActiveGroupUsers()) + legacyNetworkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, []*zones.Zone{}, validatedPeersMap, resourcePolicies, routers, nil, account.GetActiveGroupUsers()) normalizeAndSortNetworkMap(legacyNetworkMap) legacyJSON, err := json.MarshalIndent(toNetworkMapJSON(legacyNetworkMap), "", " ") require.NoError(t, err, "error marshaling legacy network map to JSON") @@ -402,7 +403,7 @@ func BenchmarkGetPeerNetworkMap_AfterRouterPeerAdded(b *testing.B) { b.Run("old builder after add", func(b *testing.B) { for i := 0; i < b.N; i++ { for _, testingPeerID := range peerIDs { - _ = account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, nil, validatedPeersMap, nil, nil, nil, account.GetActiveGroupUsers()) + _ = account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, []*zones.Zone{}, validatedPeersMap, nil, nil, nil, account.GetActiveGroupUsers()) } } }) @@ -458,7 +459,7 @@ func TestGetPeerNetworkMap_Golden_WithDeletedPeer(t *testing.T) { resourcePolicies := account.GetResourcePoliciesMap() routers := account.GetResourceRoutersMap() - legacyNetworkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, nil, validatedPeersMap, resourcePolicies, routers, nil, account.GetActiveGroupUsers()) + legacyNetworkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, []*zones.Zone{}, validatedPeersMap, resourcePolicies, routers, nil, account.GetActiveGroupUsers()) normalizeAndSortNetworkMap(legacyNetworkMap) legacyJSON, err := json.MarshalIndent(toNetworkMapJSON(legacyNetworkMap), "", " ") require.NoError(t, err, "error marshaling legacy network map to JSON") @@ -537,7 +538,7 @@ func TestGetPeerNetworkMap_Golden_WithDeletedRouterPeer(t *testing.T) { resourcePolicies := account.GetResourcePoliciesMap() routers := account.GetResourceRoutersMap() - legacyNetworkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, nil, validatedPeersMap, resourcePolicies, routers, nil, account.GetActiveGroupUsers()) + legacyNetworkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, []*zones.Zone{}, validatedPeersMap, resourcePolicies, routers, nil, account.GetActiveGroupUsers()) normalizeAndSortNetworkMap(legacyNetworkMap) legacyJSON, err := json.MarshalIndent(toNetworkMapJSON(legacyNetworkMap), "", " ") require.NoError(t, err, "error marshaling legacy network map to JSON") @@ -597,7 +598,7 @@ func BenchmarkGetPeerNetworkMap_AfterPeerDeleted(b *testing.B) { b.Run("old builder after delete", func(b *testing.B) { for i := 0; i < b.N; i++ { for _, testingPeerID := range peerIDs { - _ = account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, nil, validatedPeersMap, nil, nil, nil, account.GetActiveGroupUsers()) + _ = account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, []*zones.Zone{}, validatedPeersMap, nil, nil, nil, account.GetActiveGroupUsers()) } } }) diff --git a/management/server/types/proxy.go b/management/server/types/proxy.go new file mode 100644 index 000000000..1b80e80d1 --- /dev/null +++ b/management/server/types/proxy.go @@ -0,0 +1,7 @@ +package types + +// ProxyCallbackEndpoint holds the proxy callback endpoint +const ProxyCallbackEndpoint = "/reverse-proxy/callback" + +// ProxyCallbackEndpointFull holds the proxy callback endpoint with api suffix +const ProxyCallbackEndpointFull = "/api" + ProxyCallbackEndpoint diff --git a/management/server/types/proxy_access_token.go b/management/server/types/proxy_access_token.go new file mode 100644 index 000000000..b20b83bc1 --- /dev/null +++ b/management/server/types/proxy_access_token.go @@ -0,0 +1,137 @@ +package types + +import ( + "crypto/sha256" + "encoding/base64" + "fmt" + "hash/crc32" + "strings" + "time" + + b "github.com/hashicorp/go-secure-stdlib/base62" + "github.com/rs/xid" + + "github.com/netbirdio/netbird/base62" + "github.com/netbirdio/netbird/management/server/util" +) + +const ( + // ProxyTokenPrefix is the globally used prefix for proxy access tokens + ProxyTokenPrefix = "nbx_" + // ProxyTokenSecretLength is the number of characters used for the secret + ProxyTokenSecretLength = 30 + // ProxyTokenChecksumLength is the number of characters used for the encoded checksum + ProxyTokenChecksumLength = 6 + // ProxyTokenLength is the total number of characters used for the token + ProxyTokenLength = 40 +) + +// HashedProxyToken is a SHA-256 hash of a plain proxy token, base64-encoded. +type HashedProxyToken string + +// PlainProxyToken is the raw token string displayed once at creation time. +type PlainProxyToken string + +// ProxyAccessToken holds information about a proxy access token including a hashed version for verification +type ProxyAccessToken struct { + ID string `gorm:"primaryKey"` + Name string + HashedToken HashedProxyToken `gorm:"type:varchar(255);uniqueIndex"` + // AccountID is nil for management-wide tokens, set for account-scoped tokens + AccountID *string `gorm:"index"` + ExpiresAt *time.Time + CreatedBy string + CreatedAt time.Time + LastUsed *time.Time + Revoked bool +} + +// IsExpired returns true if the token has expired +func (t *ProxyAccessToken) IsExpired() bool { + if t.ExpiresAt == nil { + return false + } + return time.Now().After(*t.ExpiresAt) +} + +// IsValid returns true if the token is not revoked and not expired +func (t *ProxyAccessToken) IsValid() bool { + return !t.Revoked && !t.IsExpired() +} + +// ProxyAccessTokenGenerated holds the new token and the plain text version +type ProxyAccessTokenGenerated struct { + PlainToken PlainProxyToken + ProxyAccessToken +} + +// CreateNewProxyAccessToken generates a new proxy access token. +// Returns the token with hashed value stored and plain token for one-time display. +func CreateNewProxyAccessToken(name string, expiresIn time.Duration, accountID *string, createdBy string) (*ProxyAccessTokenGenerated, error) { + hashedToken, plainToken, err := generateProxyToken() + if err != nil { + return nil, err + } + + currentTime := time.Now().UTC() + var expiresAt *time.Time + if expiresIn > 0 { + expiresAt = util.ToPtr(currentTime.Add(expiresIn)) + } + + return &ProxyAccessTokenGenerated{ + ProxyAccessToken: ProxyAccessToken{ + ID: xid.New().String(), + Name: name, + HashedToken: hashedToken, + AccountID: accountID, + ExpiresAt: expiresAt, + CreatedBy: createdBy, + CreatedAt: currentTime, + Revoked: false, + }, + PlainToken: plainToken, + }, nil +} + +func generateProxyToken() (HashedProxyToken, PlainProxyToken, error) { + secret, err := b.Random(ProxyTokenSecretLength) + if err != nil { + return "", "", err + } + + checksum := crc32.ChecksumIEEE([]byte(secret)) + encodedChecksum := base62.Encode(checksum) + paddedChecksum := fmt.Sprintf("%06s", encodedChecksum) + plainToken := PlainProxyToken(ProxyTokenPrefix + secret + paddedChecksum) + return plainToken.Hash(), plainToken, nil +} + +// Hash returns the SHA-256 hash of the plain token, base64-encoded. +func (t PlainProxyToken) Hash() HashedProxyToken { + h := sha256.Sum256([]byte(t)) + return HashedProxyToken(base64.StdEncoding.EncodeToString(h[:])) +} + +// Validate checks the format of a proxy token without checking the database. +func (t PlainProxyToken) Validate() error { + if !strings.HasPrefix(string(t), ProxyTokenPrefix) { + return fmt.Errorf("invalid token prefix") + } + + if len(t) != ProxyTokenLength { + return fmt.Errorf("invalid token length") + } + + secret := t[len(ProxyTokenPrefix) : len(t)-ProxyTokenChecksumLength] + checksumStr := t[len(t)-ProxyTokenChecksumLength:] + + expectedChecksum := crc32.ChecksumIEEE([]byte(secret)) + expectedChecksumStr := fmt.Sprintf("%06s", base62.Encode(expectedChecksum)) + + if string(checksumStr) != expectedChecksumStr { + return fmt.Errorf("invalid token checksum") + } + + return nil +} diff --git a/management/server/types/proxy_access_token_test.go b/management/server/types/proxy_access_token_test.go new file mode 100644 index 000000000..aa1a4d2dd --- /dev/null +++ b/management/server/types/proxy_access_token_test.go @@ -0,0 +1,155 @@ +package types + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPlainProxyToken_Validate(t *testing.T) { + tests := []struct { + name string + token PlainProxyToken + wantErr bool + errMsg string + }{ + { + name: "valid token", + token: "", // will be generated + wantErr: false, + }, + { + name: "wrong prefix", + token: "xyz_8FbPkxioCFmlvCTJbD1RafygfVmS9z15lyNM", + wantErr: true, + errMsg: "invalid token prefix", + }, + { + name: "too short", + token: "nbx_short", + wantErr: true, + errMsg: "invalid token length", + }, + { + name: "too long", + token: "nbx_8FbPkxioCFmlvCTJbD1RafygfVmS9z15lyNMextra", + wantErr: true, + errMsg: "invalid token length", + }, + { + name: "correct length but invalid checksum", + token: "nbx_invalidtoken123456789012345678901234", // exactly 40 chars, invalid checksum + wantErr: true, + errMsg: "invalid token checksum", + }, + { + name: "empty token", + token: "", + wantErr: true, + errMsg: "invalid token prefix", + }, + { + name: "only prefix", + token: "nbx_", + wantErr: true, + errMsg: "invalid token length", + }, + } + + // Generate a valid token for the first test + generated, err := CreateNewProxyAccessToken("test", 0, nil, "test") + require.NoError(t, err) + tests[0].token = generated.PlainToken + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.token.Validate() + if tt.wantErr { + assert.Error(t, err) + if tt.errMsg != "" { + assert.Contains(t, err.Error(), tt.errMsg) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestPlainProxyToken_Hash(t *testing.T) { + token1 := PlainProxyToken("nbx_8FbPkxioCFmlvCTJbD1RafygfVmS9z15lyNM") + token2 := PlainProxyToken("nbx_8FbPkxioCFmlvCTJbD1RafygfVmS9z15lyNM") + token3 := PlainProxyToken("nbx_differenttoken1234567890123456789X") + + hash1 := token1.Hash() + hash2 := token2.Hash() + hash3 := token3.Hash() + + assert.Equal(t, hash1, hash2, "same token should produce same hash") + assert.NotEqual(t, hash1, hash3, "different tokens should produce different hashes") + assert.NotEmpty(t, hash1) +} + +func TestCreateNewProxyAccessToken(t *testing.T) { + t.Run("creates valid token", func(t *testing.T) { + generated, err := CreateNewProxyAccessToken("test-token", 0, nil, "test-user") + require.NoError(t, err) + + assert.NotEmpty(t, generated.ID) + assert.Equal(t, "test-token", generated.Name) + assert.Equal(t, "test-user", generated.CreatedBy) + assert.NotEmpty(t, generated.HashedToken) + assert.NotEmpty(t, generated.PlainToken) + assert.Nil(t, generated.ExpiresAt) + assert.False(t, generated.Revoked) + + assert.NoError(t, generated.PlainToken.Validate()) + assert.Equal(t, ProxyTokenLength, len(generated.PlainToken)) + assert.Equal(t, ProxyTokenPrefix, string(generated.PlainToken[:len(ProxyTokenPrefix)])) + }) + + t.Run("tokens are unique", func(t *testing.T) { + gen1, err := CreateNewProxyAccessToken("test1", 0, nil, "user") + require.NoError(t, err) + + gen2, err := CreateNewProxyAccessToken("test2", 0, nil, "user") + require.NoError(t, err) + + assert.NotEqual(t, gen1.PlainToken, gen2.PlainToken) + assert.NotEqual(t, gen1.HashedToken, gen2.HashedToken) + assert.NotEqual(t, gen1.ID, gen2.ID) + }) +} + +func TestProxyAccessToken_IsExpired(t *testing.T) { + past := time.Now().Add(-1 * time.Hour) + future := time.Now().Add(1 * time.Hour) + + t.Run("expired token", func(t *testing.T) { + token := &ProxyAccessToken{ExpiresAt: &past} + assert.True(t, token.IsExpired()) + }) + + t.Run("not expired token", func(t *testing.T) { + token := &ProxyAccessToken{ExpiresAt: &future} + assert.False(t, token.IsExpired()) + }) + + t.Run("no expiration", func(t *testing.T) { + token := &ProxyAccessToken{ExpiresAt: nil} + assert.False(t, token.IsExpired()) + }) +} + +func TestProxyAccessToken_IsValid(t *testing.T) { + token := &ProxyAccessToken{ + Revoked: false, + } + + assert.True(t, token.IsValid()) + + token.Revoked = true + assert.False(t, token.IsValid()) +} diff --git a/management/server/types/settings.go b/management/server/types/settings.go index 867e12bef..4ea79ec72 100644 --- a/management/server/types/settings.go +++ b/management/server/types/settings.go @@ -47,6 +47,11 @@ type Settings struct { // NetworkRange is the custom network range for that account NetworkRange netip.Prefix `gorm:"serializer:json"` + // PeerExposeEnabled enables or disables peer-initiated service expose + PeerExposeEnabled bool + // PeerExposeGroups list of peer group IDs allowed to expose services + PeerExposeGroups []string `gorm:"serializer:json"` + // Extra is a dictionary of Account settings Extra *ExtraSettings `gorm:"embedded;embeddedPrefix:extra_"` @@ -55,6 +60,18 @@ type Settings struct { // AutoUpdateVersion client auto-update version AutoUpdateVersion string `gorm:"default:'disabled'"` + + // AutoUpdateAlways when true, updates are installed automatically in the background; + // when false, updates require user interaction from the UI + AutoUpdateAlways bool `gorm:"default:false"` + + // EmbeddedIdpEnabled indicates if the embedded identity provider is enabled. + // This is a runtime-only field, not stored in the database. + EmbeddedIdpEnabled bool `gorm:"-"` + + // LocalAuthDisabled indicates if local (email/password) authentication is disabled. + // This is a runtime-only field, not stored in the database. + LocalAuthDisabled bool `gorm:"-"` } // Copy copies the Settings struct @@ -72,10 +89,15 @@ func (s *Settings) Copy() *Settings { PeerInactivityExpiration: s.PeerInactivityExpiration, RoutingPeerDNSResolutionEnabled: s.RoutingPeerDNSResolutionEnabled, + PeerExposeEnabled: s.PeerExposeEnabled, + PeerExposeGroups: slices.Clone(s.PeerExposeGroups), LazyConnectionEnabled: s.LazyConnectionEnabled, DNSDomain: s.DNSDomain, NetworkRange: s.NetworkRange, AutoUpdateVersion: s.AutoUpdateVersion, + AutoUpdateAlways: s.AutoUpdateAlways, + EmbeddedIdpEnabled: s.EmbeddedIdpEnabled, + LocalAuthDisabled: s.LocalAuthDisabled, } if s.Extra != nil { settings.Extra = s.Extra.Copy() diff --git a/management/server/user.go b/management/server/user.go index 51da7a633..327aec2d0 100644 --- a/management/server/user.go +++ b/management/server/user.go @@ -191,6 +191,10 @@ func (am *DefaultAccountManager) createNewIdpUser(ctx context.Context, accountID // Unlike createNewIdpUser, this method fetches user data directly from the database // since the embedded IdP usage ensures the username and email are stored locally in the User table. func (am *DefaultAccountManager) createEmbeddedIdpUser(ctx context.Context, accountID string, inviterID string, invite *types.UserInfo) (*idp.UserData, error) { + if IsLocalAuthDisabled(ctx, am.idpManager) { + return nil, status.Errorf(status.PreconditionFailed, "local user creation is disabled - use an external identity provider") + } + inviter, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, inviterID) if err != nil { return nil, fmt.Errorf("failed to get inviter user: %w", err) @@ -733,6 +737,19 @@ func (am *DefaultAccountManager) processUserUpdate(ctx context.Context, transact return false, nil, nil, nil, status.Errorf(status.InvalidArgument, "provided user update is nil") } + if initiatorUserId != activity.SystemInitiator { + freshInitiator, err := transaction.GetUserByUserID(ctx, store.LockingStrengthUpdate, initiatorUserId) + if err != nil { + return false, nil, nil, nil, fmt.Errorf("failed to re-read initiator user in transaction: %w", err) + } + + // Ensure the initiator still has admin privileges + if initiatorUser.HasAdminPower() && !freshInitiator.HasAdminPower() { + return false, nil, nil, nil, status.Errorf(status.PermissionDenied, "initiator role was changed during request processing") + } + initiatorUser = freshInitiator + } + oldUser, isNewUser, err := getUserOrCreateIfNotExists(ctx, transaction, accountID, update, addIfNotExists) if err != nil { return false, nil, nil, nil, err @@ -860,7 +877,6 @@ func validateUserUpdate(groupsMap map[string]*types.Group, initiatorUser, oldUse return nil } - // @todo double check these if initiatorUser.HasAdminPower() && initiatorUser.Id == update.Id && oldUser.Blocked != update.Blocked { return status.Errorf(status.PermissionDenied, "admins can't block or unblock themselves") } @@ -1462,6 +1478,10 @@ func (am *DefaultAccountManager) CreateUserInvite(ctx context.Context, accountID return nil, status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider") } + if IsLocalAuthDisabled(ctx, am.idpManager) { + return nil, status.Errorf(status.PreconditionFailed, "local user creation is disabled - use an external identity provider") + } + if err := validateUserInvite(invite); err != nil { return nil, err } @@ -1621,6 +1641,10 @@ func (am *DefaultAccountManager) AcceptUserInvite(ctx context.Context, token, pa return status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider") } + if IsLocalAuthDisabled(ctx, am.idpManager) { + return status.Errorf(status.PreconditionFailed, "local user creation is disabled - use an external identity provider") + } + if password == "" { return status.Errorf(status.InvalidArgument, "password is required") } diff --git a/management/server/user_test.go b/management/server/user_test.go index 2dd1cea2e..800d2406c 100644 --- a/management/server/user_test.go +++ b/management/server/user_test.go @@ -2031,3 +2031,66 @@ func TestUser_Operations_WithEmbeddedIDP(t *testing.T) { t.Logf("Duplicate email error: %v", err) }) } + +func TestProcessUserUpdate_RejectsStaleInitiatorRole(t *testing.T) { + s, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir()) + require.NoError(t, err) + t.Cleanup(cleanup) + + account := newAccountWithId(context.Background(), "account1", "owner1", "", "", "", false) + + adminID := "admin1" + account.Users[adminID] = types.NewAdminUser(adminID) + + targetID := "target1" + account.Users[targetID] = types.NewRegularUser(targetID, "", "") + + require.NoError(t, s.SaveAccount(context.Background(), account)) + + demotedAdmin, err := s.GetUserByUserID(context.Background(), store.LockingStrengthNone, adminID) + require.NoError(t, err) + demotedAdmin.Role = types.UserRoleUser + require.NoError(t, s.SaveUser(context.Background(), demotedAdmin)) + + staleInitiator := &types.User{ + Id: adminID, + AccountID: account.Id, + Role: types.UserRoleAdmin, + } + + permissionsManager := permissions.NewManager(s) + am := DefaultAccountManager{ + Store: s, + eventStore: &activity.InMemoryEventStore{}, + permissionsManager: permissionsManager, + } + + settings, err := s.GetAccountSettings(context.Background(), store.LockingStrengthNone, account.Id) + require.NoError(t, err) + + groups, err := s.GetAccountGroups(context.Background(), store.LockingStrengthNone, account.Id) + require.NoError(t, err) + groupsMap := make(map[string]*types.Group, len(groups)) + for _, g := range groups { + groupsMap[g.ID] = g + } + + update := &types.User{ + Id: targetID, + Role: types.UserRoleAdmin, + } + + err = s.ExecuteInTransaction(context.Background(), func(tx store.Store) error { + _, _, _, _, txErr := am.processUserUpdate( + context.Background(), tx, groupsMap, account.Id, adminID, staleInitiator, update, false, settings, + ) + return txErr + }) + + require.Error(t, err, "processUserUpdate should reject stale initiator whose role was demoted") + assert.Contains(t, err.Error(), "initiator role was changed during request processing") + + targetUser, err := s.GetUserByUserID(context.Background(), store.LockingStrengthNone, targetID) + require.NoError(t, err) + assert.Equal(t, types.UserRoleUser, targetUser.Role) +} diff --git a/management/server/util/util.go b/management/server/util/util.go index eea6a72b0..617484274 100644 --- a/management/server/util/util.go +++ b/management/server/util/util.go @@ -1,9 +1,5 @@ package util -import "regexp" - -var domainRegex = regexp.MustCompile(`^(\*\.)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$`) - // Difference returns the elements in `a` that aren't in `b`. func Difference(a, b []string) []string { mb := make(map[string]struct{}, len(b)) @@ -54,10 +50,3 @@ func contains[T comparableObject[T]](slice []T, element T) bool { } return false } - -func IsValidDomain(domain string) bool { - if domain == "" { - return false - } - return domainRegex.MatchString(domain) -} diff --git a/proxy/Dockerfile b/proxy/Dockerfile new file mode 100644 index 000000000..e64680fd6 --- /dev/null +++ b/proxy/Dockerfile @@ -0,0 +1,19 @@ +FROM golang:1.25-alpine AS builder +WORKDIR /app + +RUN echo "netbird:x:1000:1000:netbird:/var/lib/netbird:/sbin/nologin" > /tmp/passwd && \ + echo "netbird:x:1000:netbird" > /tmp/group && \ + mkdir -p /tmp/var/lib/netbird && \ + mkdir -p /tmp/certs + +FROM gcr.io/distroless/base:debug +COPY netbird-proxy /go/bin/netbird-proxy +COPY --from=builder /tmp/passwd /etc/passwd +COPY --from=builder /tmp/group /etc/group +COPY --from=builder --chown=1000:1000 /tmp/var/lib/netbird /var/lib/netbird +COPY --from=builder --chown=1000:1000 --chmod=755 /tmp/certs /certs +USER netbird:netbird +ENV HOME=/var/lib/netbird +ENV NB_PROXY_ADDRESS=":8443" +EXPOSE 8443 +ENTRYPOINT ["/go/bin/netbird-proxy"] diff --git a/proxy/Dockerfile.multistage b/proxy/Dockerfile.multistage new file mode 100644 index 000000000..01e342c0e --- /dev/null +++ b/proxy/Dockerfile.multistage @@ -0,0 +1,37 @@ +FROM golang:1.25-alpine AS builder +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY client ./client +COPY dns ./dns +COPY encryption ./encryption +COPY flow ./flow +COPY formatter ./formatter +COPY monotime ./monotime +COPY proxy ./proxy +COPY route ./route +COPY shared ./shared +COPY sharedsock ./sharedsock +COPY upload-server ./upload-server +COPY util ./util +COPY version ./version +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o netbird-proxy ./proxy/cmd/proxy + +RUN echo "netbird:x:1000:1000:netbird:/var/lib/netbird:/sbin/nologin" > /tmp/passwd && \ + echo "netbird:x:1000:netbird" > /tmp/group && \ + mkdir -p /tmp/var/lib/netbird && \ + mkdir -p /tmp/certs + +FROM gcr.io/distroless/base:debug +COPY --from=builder /app/netbird-proxy /usr/bin/netbird-proxy +COPY --from=builder /tmp/passwd /etc/passwd +COPY --from=builder /tmp/group /etc/group +COPY --from=builder --chown=1000:1000 /tmp/var/lib/netbird /var/lib/netbird +COPY --from=builder --chown=1000:1000 --chmod=755 /tmp/certs /certs +USER netbird:netbird +ENV HOME=/var/lib/netbird +ENV NB_PROXY_ADDRESS=":8443" +EXPOSE 8443 +ENTRYPOINT ["/usr/bin/netbird-proxy"] diff --git a/proxy/LICENSE b/proxy/LICENSE new file mode 100644 index 000000000..be3f7b28e --- /dev/null +++ b/proxy/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/proxy/README.md b/proxy/README.md new file mode 100644 index 000000000..6af7cadd2 --- /dev/null +++ b/proxy/README.md @@ -0,0 +1,80 @@ +# Netbird Reverse Proxy + +The NetBird Reverse Proxy is a separate service that can act as a public entrypoint to certain resources within a NetBird network. +At a high level, the way that it operates is: +- Configured routes are communicated from the Management server to the proxy. +- For each route the proxy creates a NetBird connection to the NetBird Peer that hosts the resource. +- When traffic hits the proxy at the address and path configured for the proxied resource, the NetBird Proxy brings up a relevant authentication method for that resource. +- On successful authentication the proxy will forward traffic onwards to the NetBird Peer. + +Proxy Authentication methods supported are: +- No authentication +- Oauth2/OIDC +- Emailed Magic Link +- Simple PIN +- HTTP Basic Auth Username and Password + +## Management Connection and Authentication + +The Proxy communicates with the Management server over a gRPC connection. +Proxies act as clients to the Management server, the following RPCs are used: +- Server-side streaming for proxied service updates. +- Client-side streaming for proxy logs. + +To authenticate with the Management server, the proxy server uses Machine-to-Machine OAuth2. +If you are using the embedded IdP //TODO: explain how to get credentials. +Otherwise, create a new machine-to-machine profile in your IdP for proxy servers and set the relevant settings in the proxy's environment or flags (see below). + +## User Authentication + +When a request hits the Proxy, it looks up the permitted authentication methods for the Host domain. +If no authentication methods are registered for the Host domain, then no authentication will be applied (for fully public resources). +If any authentication methods are registered for the Host domain, then the Proxy will first serve an authentication page allowing the user to select an authentication method (from the permitted methods) and enter the required information for that authentication method. +If the user is successfully authenticated, their request will be forwarded through to the Proxy to be proxied to the relevant Peer. +Successful authentication does not guarantee a successful forwarding of the request as there may be failures behind the Proxy, such as with Peer connectivity or the underlying resource. + +## TLS + +Due to the authentication provided, the Proxy uses HTTPS for its endpoint, even if the underlying service is HTTP. +Certificate generation can either be via ACME (by default, using Let's Encrypt, but alternative ACME providers can be used) or through certificate files. +When not using ACME, the proxy server attempts to load a certificate and key from the files `tls.crt` and `tls.key` in a specified certificate directory. +When using ACME, the proxy server will store generated certificates in the specified certificate directory. + + +## Auth UI + +The authentication UI is a Vite + React application located in the `web/` directory. It is embedded into the Go binary at build time. + +To build the UI: +```bash +cd web +npm install +npm run build +``` + +For UI development with hot reload (served at http://localhost:3031): +```bash +npm run dev +``` + +The built assets in `web/dist/` are embedded via `//go:embed` and served by the `web.ServeHTTP` handler. + +## Configuration + +NetBird Proxy deployment configuration is via flags or environment variables, with flags taking precedence over the environment. +The following deployment configuration is available: + +| Flag | Env | Purpose | Default | +|------------------|----------------------------------|------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------| +| `-debug` | `NB_PROXY_DEBUG_LOGS` | Enable debug logging | `false` | +| `-mgmt` | `NB_PROXY_MANAGEMENT_ADDRESS` | The address of the management server for the proxy to get configuration from. | `"https://api.netbird.io:443"` | +| `-addr` | `NB_PROXY_ADDRESS` | The address that the reverse proxy will listen on. | `":443` | +| `-url` | `NB_PROXY_URL` | The URL that the proxy will be reached at (where endpoints will be CNAMEd to). If unset, this will fall back to the proxy address. | `"proxy.netbird.io"` | +| `-cert-dir` | `NB_PROXY_CERTIFICATE_DIRECTORY` | The location that certificates are stored in. | `"./certs"` | +| `-acme-certs` | `NB_PROXY_ACME_CERTIFICATES` | Whether to use ACME to generate certificates. | `false` | +| `-acme-addr` | `NB_PROXY_ACME_ADDRESS` | The HTTP address the proxy will listen on to respond to HTTP-01 ACME challenges | `":80"` | +| `-acme-dir` | `NB_PROXY_ACME_DIRECTORY` | The directory URL of the ACME server to be used | `"https://acme-v02.api.letsencrypt.org/directory"` | +| `-oidc-id` | `NB_PROXY_OIDC_CLIENT_ID` | The OAuth2 Client ID for OIDC User Authentication | `"netbird-proxy"` | +| `-oidc-secret` | `NB_PROXY_OIDC_CLIENT_SECRET` | The OAuth2 Client Secret for OIDC User Authentication | `""` | +| `-oidc-endpoint` | `NB_PROXY_OIDC_ENDPOINT` | The OAuth2 provider endpoint for OIDC User Authentication | `"https://api.netbird.io/oauth2"` | +| `-oidc-scopes` | `NB_PROXY_OIDC_SCOPES` | The OAuth2 scopes for OIDC User Authentication, comma separated | `"openid,profile,email"` | diff --git a/proxy/auth/auth.go b/proxy/auth/auth.go new file mode 100644 index 000000000..ca9c260b7 --- /dev/null +++ b/proxy/auth/auth.go @@ -0,0 +1,77 @@ +// Package auth contains exported proxy auth values. +// These are used to ensure coherent usage across management and proxy implementations. +package auth + +import ( + "crypto/ed25519" + "crypto/tls" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type Method string + +const ( + MethodPassword Method = "password" + MethodPIN Method = "pin" + MethodOIDC Method = "oidc" + MethodHeader Method = "header" +) + +func (m Method) String() string { + return string(m) +} + +const ( + SessionCookieName = "nb_session" + DefaultSessionExpiry = 24 * time.Hour + SessionJWTIssuer = "netbird-management" +) + +// ResolveProto determines the protocol scheme based on the forwarded proto +// configuration. When set to "http" or "https" the value is used directly. +// Otherwise TLS state is used: if conn is non-nil "https" is returned, else "http". +func ResolveProto(forwardedProto string, conn *tls.ConnectionState) string { + switch forwardedProto { + case "http", "https": + return forwardedProto + default: + if conn != nil { + return "https" + } + return "http" + } +} + +// ValidateSessionJWT validates a session JWT and returns the user ID and method. +func ValidateSessionJWT(tokenString, domain string, publicKey ed25519.PublicKey) (userID, method string, err error) { + if publicKey == nil { + return "", "", fmt.Errorf("no public key configured for domain") + } + + token, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodEd25519); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return publicKey, nil + }, jwt.WithAudience(domain), jwt.WithIssuer(SessionJWTIssuer)) + if err != nil { + return "", "", fmt.Errorf("parse token: %w", err) + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok || !token.Valid { + return "", "", fmt.Errorf("invalid token claims") + } + + sub, _ := claims.GetSubject() + if sub == "" { + return "", "", fmt.Errorf("missing subject claim") + } + + methodClaim, _ := claims["method"].(string) + + return sub, methodClaim, nil +} diff --git a/proxy/cmd/proxy/cmd/debug.go b/proxy/cmd/proxy/cmd/debug.go new file mode 100644 index 000000000..59f7a6b65 --- /dev/null +++ b/proxy/cmd/proxy/cmd/debug.go @@ -0,0 +1,173 @@ +package cmd + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + + "github.com/netbirdio/netbird/proxy/internal/debug" +) + +var ( + debugAddr string + jsonOutput bool + + // status filters + statusFilterByIPs []string + statusFilterByNames []string + statusFilterByStatus string + statusFilterByConnectionType string +) + +var debugCmd = &cobra.Command{ + Use: "debug", + Short: "Debug commands for inspecting proxy state", + Long: "Debug commands for inspecting the reverse proxy state via the debug HTTP endpoint.", +} + +var debugHealthCmd = &cobra.Command{ + Use: "health", + Short: "Show proxy health status", + RunE: runDebugHealth, + SilenceUsage: true, +} + +var debugClientsCmd = &cobra.Command{ + Use: "clients", + Aliases: []string{"list"}, + Short: "List all connected clients", + RunE: runDebugClients, + SilenceUsage: true, +} + +var debugStatusCmd = &cobra.Command{ + Use: "status ", + Short: "Show client status", + Args: cobra.ExactArgs(1), + RunE: runDebugStatus, + SilenceUsage: true, +} + +var debugSyncCmd = &cobra.Command{ + Use: "sync-response ", + Short: "Show client sync response", + Args: cobra.ExactArgs(1), + RunE: runDebugSync, + SilenceUsage: true, +} + +var pingTimeout string + +var debugPingCmd = &cobra.Command{ + Use: "ping [port]", + Short: "TCP ping through a client", + Long: "Perform a TCP ping through a client's network to test connectivity.\nPort defaults to 80 if not specified.", + Args: cobra.RangeArgs(2, 3), + RunE: runDebugPing, + SilenceUsage: true, +} + +var debugLogCmd = &cobra.Command{ + Use: "log", + Short: "Manage client logging", + Long: "Commands to manage logging settings for a client connected through the proxy.", +} + +var debugLogLevelCmd = &cobra.Command{ + Use: "level ", + Short: "Set client log level", + Long: "Set the log level for a client (trace, debug, info, warn, error).", + Args: cobra.ExactArgs(2), + RunE: runDebugLogLevel, + SilenceUsage: true, +} + +var debugStartCmd = &cobra.Command{ + Use: "start ", + Short: "Start a client", + Args: cobra.ExactArgs(1), + RunE: runDebugStart, + SilenceUsage: true, +} + +var debugStopCmd = &cobra.Command{ + Use: "stop ", + Short: "Stop a client", + Args: cobra.ExactArgs(1), + RunE: runDebugStop, + SilenceUsage: true, +} + +func init() { + debugCmd.PersistentFlags().StringVar(&debugAddr, "addr", envStringOrDefault("NB_PROXY_DEBUG_ADDRESS", "localhost:8444"), "Debug endpoint address") + debugCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Output JSON instead of pretty format") + + debugStatusCmd.Flags().StringSliceVar(&statusFilterByIPs, "filter-by-ips", nil, "Filter by peer IPs (comma-separated)") + debugStatusCmd.Flags().StringSliceVar(&statusFilterByNames, "filter-by-names", nil, "Filter by peer names (comma-separated)") + debugStatusCmd.Flags().StringVar(&statusFilterByStatus, "filter-by-status", "", "Filter by status (idle|connecting|connected)") + debugStatusCmd.Flags().StringVar(&statusFilterByConnectionType, "filter-by-connection-type", "", "Filter by connection type (P2P|Relayed)") + + debugPingCmd.Flags().StringVar(&pingTimeout, "timeout", "", "Ping timeout (e.g., 10s)") + + debugCmd.AddCommand(debugHealthCmd) + debugCmd.AddCommand(debugClientsCmd) + debugCmd.AddCommand(debugStatusCmd) + debugCmd.AddCommand(debugSyncCmd) + debugCmd.AddCommand(debugPingCmd) + debugLogCmd.AddCommand(debugLogLevelCmd) + debugCmd.AddCommand(debugLogCmd) + debugCmd.AddCommand(debugStartCmd) + debugCmd.AddCommand(debugStopCmd) + + rootCmd.AddCommand(debugCmd) +} + +func getDebugClient(cmd *cobra.Command) *debug.Client { + return debug.NewClient(debugAddr, jsonOutput, cmd.OutOrStdout()) +} + +func runDebugHealth(cmd *cobra.Command, _ []string) error { + return getDebugClient(cmd).Health(cmd.Context()) +} + +func runDebugClients(cmd *cobra.Command, _ []string) error { + return getDebugClient(cmd).ListClients(cmd.Context()) +} + +func runDebugStatus(cmd *cobra.Command, args []string) error { + return getDebugClient(cmd).ClientStatus(cmd.Context(), args[0], debug.StatusFilters{ + IPs: statusFilterByIPs, + Names: statusFilterByNames, + Status: statusFilterByStatus, + ConnectionType: statusFilterByConnectionType, + }) +} + +func runDebugSync(cmd *cobra.Command, args []string) error { + return getDebugClient(cmd).ClientSyncResponse(cmd.Context(), args[0]) +} + +func runDebugPing(cmd *cobra.Command, args []string) error { + port := 80 + if len(args) > 2 { + p, err := strconv.Atoi(args[2]) + if err != nil { + return fmt.Errorf("invalid port: %w", err) + } + port = p + } + return getDebugClient(cmd).PingTCP(cmd.Context(), args[0], args[1], port, pingTimeout) +} + +func runDebugLogLevel(cmd *cobra.Command, args []string) error { + return getDebugClient(cmd).SetLogLevel(cmd.Context(), args[0], args[1]) +} + +func runDebugStart(cmd *cobra.Command, args []string) error { + return getDebugClient(cmd).StartClient(cmd.Context(), args[0]) +} + +func runDebugStop(cmd *cobra.Command, args []string) error { + return getDebugClient(cmd).StopClient(cmd.Context(), args[0]) +} diff --git a/proxy/cmd/proxy/cmd/root.go b/proxy/cmd/proxy/cmd/root.go new file mode 100644 index 000000000..a2252cc20 --- /dev/null +++ b/proxy/cmd/proxy/cmd/root.go @@ -0,0 +1,240 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "os/signal" + "strconv" + "syscall" + "time" + + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "golang.org/x/crypto/acme" + + "github.com/netbirdio/netbird/shared/management/domain" + + "github.com/netbirdio/netbird/proxy" + nbacme "github.com/netbirdio/netbird/proxy/internal/acme" + "github.com/netbirdio/netbird/util" +) + +const DefaultManagementURL = "https://api.netbird.io:443" + +// envProxyToken is the environment variable name for the proxy access token. +// +//nolint:gosec +const envProxyToken = "NB_PROXY_TOKEN" + +var ( + Version = "dev" + Commit = "unknown" + BuildDate = "unknown" + GoVersion = "unknown" +) + +var ( + logLevel string + debugLogs bool + mgmtAddr string + addr string + proxyDomain string + maxDialTimeout time.Duration + maxSessionIdleTimeout time.Duration + certDir string + acmeCerts bool + acmeAddr string + acmeDir string + acmeEABKID string + acmeEABHMACKey string + acmeChallengeType string + debugEndpoint bool + debugEndpointAddr string + healthAddr string + forwardedProto string + trustedProxies string + certFile string + certKeyFile string + certLockMethod string + wildcardCertDir string + wgPort uint16 + proxyProtocol bool + preSharedKey string + supportsCustomPorts bool + geoDataDir string +) + +var rootCmd = &cobra.Command{ + Use: "proxy", + Short: "NetBird reverse proxy server", + Long: "NetBird reverse proxy server for proxying traffic to NetBird networks.", + Version: Version, + SilenceUsage: true, + RunE: runServer, +} + +func init() { + rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", envStringOrDefault("NB_PROXY_LOG_LEVEL", "info"), "Log level: panic, fatal, error, warn, info, debug, trace") + rootCmd.PersistentFlags().BoolVar(&debugLogs, "debug", envBoolOrDefault("NB_PROXY_DEBUG_LOGS", false), "Enable debug logs") + _ = rootCmd.PersistentFlags().MarkDeprecated("debug", "use --log-level instead") + rootCmd.Flags().StringVar(&mgmtAddr, "mgmt", envStringOrDefault("NB_PROXY_MANAGEMENT_ADDRESS", DefaultManagementURL), "Management address to connect to") + rootCmd.Flags().StringVar(&addr, "addr", envStringOrDefault("NB_PROXY_ADDRESS", ":443"), "Reverse proxy address to listen on") + rootCmd.Flags().StringVar(&proxyDomain, "domain", envStringOrDefault("NB_PROXY_DOMAIN", ""), "The Domain at which this proxy will be reached. e.g., netbird.example.com") + rootCmd.Flags().StringVar(&certDir, "cert-dir", envStringOrDefault("NB_PROXY_CERTIFICATE_DIRECTORY", "./certs"), "Directory to store certificates") + rootCmd.Flags().BoolVar(&acmeCerts, "acme-certs", envBoolOrDefault("NB_PROXY_ACME_CERTIFICATES", false), "Generate ACME certificates automatically") + rootCmd.Flags().StringVar(&acmeAddr, "acme-addr", envStringOrDefault("NB_PROXY_ACME_ADDRESS", ":80"), "HTTP address for ACME HTTP-01 challenges (only used when acme-challenge-type is http-01)") + rootCmd.Flags().StringVar(&acmeDir, "acme-dir", envStringOrDefault("NB_PROXY_ACME_DIRECTORY", acme.LetsEncryptURL), "URL of ACME challenge directory") + rootCmd.Flags().StringVar(&acmeEABKID, "acme-eab-kid", envStringOrDefault("NB_PROXY_ACME_EAB_KID", ""), "ACME EAB KID for account registration") + rootCmd.Flags().StringVar(&acmeEABHMACKey, "acme-eab-hmac-key", envStringOrDefault("NB_PROXY_ACME_EAB_HMAC_KEY", ""), "ACME EAB HMAC key for account registration") + rootCmd.Flags().StringVar(&acmeChallengeType, "acme-challenge-type", envStringOrDefault("NB_PROXY_ACME_CHALLENGE_TYPE", "tls-alpn-01"), "ACME challenge type: tls-alpn-01 (default, port 443 only) or http-01 (requires port 80)") + rootCmd.Flags().BoolVar(&debugEndpoint, "debug-endpoint", envBoolOrDefault("NB_PROXY_DEBUG_ENDPOINT", false), "Enable debug HTTP endpoint") + rootCmd.Flags().StringVar(&debugEndpointAddr, "debug-endpoint-addr", envStringOrDefault("NB_PROXY_DEBUG_ENDPOINT_ADDRESS", "localhost:8444"), "Address for the debug HTTP endpoint") + rootCmd.Flags().StringVar(&healthAddr, "health-addr", envStringOrDefault("NB_PROXY_HEALTH_ADDRESS", "localhost:8080"), "Address for the health probe endpoint (liveness/readiness/startup)") + rootCmd.Flags().StringVar(&forwardedProto, "forwarded-proto", envStringOrDefault("NB_PROXY_FORWARDED_PROTO", "auto"), "X-Forwarded-Proto value for backends: auto, http, or https") + rootCmd.Flags().StringVar(&trustedProxies, "trusted-proxies", envStringOrDefault("NB_PROXY_TRUSTED_PROXIES", ""), "Comma-separated list of trusted upstream proxy CIDR ranges (e.g. '10.0.0.0/8,192.168.1.1')") + rootCmd.Flags().StringVar(&certFile, "cert-file", envStringOrDefault("NB_PROXY_CERTIFICATE_FILE", "tls.crt"), "TLS certificate filename within the certificate directory") + rootCmd.Flags().StringVar(&certKeyFile, "cert-key-file", envStringOrDefault("NB_PROXY_CERTIFICATE_KEY_FILE", "tls.key"), "TLS certificate key filename within the certificate directory") + rootCmd.Flags().StringVar(&certLockMethod, "cert-lock-method", envStringOrDefault("NB_PROXY_CERT_LOCK_METHOD", "auto"), "Certificate lock method for cross-replica coordination: auto, flock, or k8s-lease") + rootCmd.Flags().StringVar(&wildcardCertDir, "wildcard-cert-dir", envStringOrDefault("NB_PROXY_WILDCARD_CERT_DIR", ""), "Directory containing wildcard certificate pairs (.crt/.key). Wildcard patterns are extracted from SANs automatically") + rootCmd.Flags().Uint16Var(&wgPort, "wg-port", envUint16OrDefault("NB_PROXY_WG_PORT", 0), "WireGuard listen port (0 = random). Fixed port only works with single-account deployments") + rootCmd.Flags().BoolVar(&proxyProtocol, "proxy-protocol", envBoolOrDefault("NB_PROXY_PROXY_PROTOCOL", false), "Enable PROXY protocol on TCP listeners to preserve client IPs behind L4 proxies") + rootCmd.Flags().StringVar(&preSharedKey, "preshared-key", envStringOrDefault("NB_PROXY_PRESHARED_KEY", ""), "Define a pre-shared key for the tunnel between proxy and peers") + rootCmd.Flags().BoolVar(&supportsCustomPorts, "supports-custom-ports", envBoolOrDefault("NB_PROXY_SUPPORTS_CUSTOM_PORTS", true), "Whether the proxy can bind arbitrary ports for UDP/TCP passthrough") + rootCmd.Flags().DurationVar(&maxDialTimeout, "max-dial-timeout", envDurationOrDefault("NB_PROXY_MAX_DIAL_TIMEOUT", 0), "Cap per-service backend dial timeout (0 = no cap)") + rootCmd.Flags().DurationVar(&maxSessionIdleTimeout, "max-session-idle-timeout", envDurationOrDefault("NB_PROXY_MAX_SESSION_IDLE_TIMEOUT", 0), "Cap per-service session idle timeout (0 = no cap)") + rootCmd.Flags().StringVar(&geoDataDir, "geo-data-dir", envStringOrDefault("NB_PROXY_GEO_DATA_DIR", "/var/lib/netbird/geolocation"), "Directory for the GeoLite2 MMDB file (auto-downloaded if missing)") +} + +// Execute runs the root command. +func Execute() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} + +// SetVersionInfo sets version information for the CLI. +func SetVersionInfo(version, commit, buildDate, goVersion string) { + Version = version + Commit = commit + BuildDate = buildDate + GoVersion = goVersion + rootCmd.Version = version + rootCmd.SetVersionTemplate("Version: {{.Version}}, Commit: " + Commit + ", BuildDate: " + BuildDate + ", Go: " + GoVersion + "\n") +} + +func runServer(cmd *cobra.Command, args []string) error { + proxyToken := os.Getenv(envProxyToken) + if proxyToken == "" { + return fmt.Errorf("proxy token is required: set %s environment variable", envProxyToken) + } + + level := logLevel + if debugLogs { + level = "debug" + } + logger := log.New() + + _ = util.InitLogger(logger, level, util.LogConsole) + + logger.Infof("configured log level: %s", level) + + switch forwardedProto { + case "auto", "http", "https": + default: + return fmt.Errorf("invalid --forwarded-proto value %q: must be auto, http, or https", forwardedProto) + } + + _, err := domain.ValidateDomains([]string{proxyDomain}) + if err != nil { + return fmt.Errorf("invalid domain value %q: %w", proxyDomain, err) + } + + parsedTrustedProxies, err := proxy.ParseTrustedProxies(trustedProxies) + if err != nil { + return fmt.Errorf("invalid --trusted-proxies: %w", err) + } + + srv := proxy.Server{ + Logger: logger, + Version: Version, + ManagementAddress: mgmtAddr, + ProxyURL: proxyDomain, + ProxyToken: proxyToken, + CertificateDirectory: certDir, + CertificateFile: certFile, + CertificateKeyFile: certKeyFile, + GenerateACMECertificates: acmeCerts, + ACMEChallengeAddress: acmeAddr, + ACMEDirectory: acmeDir, + ACMEEABKID: acmeEABKID, + ACMEEABHMACKey: acmeEABHMACKey, + ACMEChallengeType: acmeChallengeType, + DebugEndpointEnabled: debugEndpoint, + DebugEndpointAddress: debugEndpointAddr, + HealthAddress: healthAddr, + ForwardedProto: forwardedProto, + TrustedProxies: parsedTrustedProxies, + CertLockMethod: nbacme.CertLockMethod(certLockMethod), + WildcardCertDir: wildcardCertDir, + WireguardPort: wgPort, + ProxyProtocol: proxyProtocol, + PreSharedKey: preSharedKey, + SupportsCustomPorts: supportsCustomPorts, + MaxDialTimeout: maxDialTimeout, + MaxSessionIdleTimeout: maxSessionIdleTimeout, + GeoDataDir: geoDataDir, + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) + defer stop() + + return srv.ListenAndServe(ctx, addr) +} + +func envBoolOrDefault(key string, def bool) bool { + v, exists := os.LookupEnv(key) + if !exists { + return def + } + parsed, err := strconv.ParseBool(v) + if err != nil { + log.Warnf("parse %s=%q: %v, using default %v", key, v, err, def) + return def + } + return parsed +} + +func envStringOrDefault(key string, def string) string { + v, exists := os.LookupEnv(key) + if !exists { + return def + } + return v +} + +func envUint16OrDefault(key string, def uint16) uint16 { + v, exists := os.LookupEnv(key) + if !exists { + return def + } + parsed, err := strconv.ParseUint(v, 10, 16) + if err != nil { + log.Warnf("parse %s=%q: %v, using default %d", key, v, err, def) + return def + } + return uint16(parsed) +} + +func envDurationOrDefault(key string, def time.Duration) time.Duration { + v, exists := os.LookupEnv(key) + if !exists { + return def + } + parsed, err := time.ParseDuration(v) + if err != nil { + log.Warnf("parse %s=%q: %v, using default %s", key, v, err, def) + return def + } + return parsed +} diff --git a/proxy/cmd/proxy/main.go b/proxy/cmd/proxy/main.go new file mode 100644 index 000000000..14e540a2e --- /dev/null +++ b/proxy/cmd/proxy/main.go @@ -0,0 +1,26 @@ +package main + +import ( + "runtime" + + "github.com/netbirdio/netbird/proxy/cmd/proxy/cmd" +) + +var ( + // Version is the application version (set via ldflags during build) + Version = "dev" + + // Commit is the git commit hash (set via ldflags during build) + Commit = "unknown" + + // BuildDate is the build date (set via ldflags during build) + BuildDate = "unknown" + + // GoVersion is the Go version used to build the binary + GoVersion = runtime.Version() +) + +func main() { + cmd.SetVersionInfo(Version, Commit, BuildDate, GoVersion) + cmd.Execute() +} diff --git a/proxy/handle_mapping_stream_test.go b/proxy/handle_mapping_stream_test.go new file mode 100644 index 000000000..cb16c0814 --- /dev/null +++ b/proxy/handle_mapping_stream_test.go @@ -0,0 +1,103 @@ +package proxy + +import ( + "context" + "io" + "testing" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" + + "github.com/netbirdio/netbird/proxy/internal/health" + "github.com/netbirdio/netbird/shared/management/proto" +) + +type mockMappingStream struct { + grpc.ClientStream + messages []*proto.GetMappingUpdateResponse + idx int +} + +func (m *mockMappingStream) Recv() (*proto.GetMappingUpdateResponse, error) { + if m.idx >= len(m.messages) { + return nil, io.EOF + } + msg := m.messages[m.idx] + m.idx++ + return msg, nil +} + +func (m *mockMappingStream) Header() (metadata.MD, error) { + return nil, nil //nolint:nilnil +} +func (m *mockMappingStream) Trailer() metadata.MD { return nil } +func (m *mockMappingStream) CloseSend() error { return nil } +func (m *mockMappingStream) Context() context.Context { return context.Background() } +func (m *mockMappingStream) SendMsg(any) error { return nil } +func (m *mockMappingStream) RecvMsg(any) error { return nil } + +func closedChan() chan struct{} { + ch := make(chan struct{}) + close(ch) + return ch +} + +func TestHandleMappingStream_SyncCompleteFlag(t *testing.T) { + checker := health.NewChecker(nil, nil) + s := &Server{ + Logger: log.StandardLogger(), + healthChecker: checker, + routerReady: closedChan(), + } + + stream := &mockMappingStream{ + messages: []*proto.GetMappingUpdateResponse{ + {InitialSyncComplete: true}, + }, + } + + syncDone := false + err := s.handleMappingStream(context.Background(), stream, &syncDone) + assert.NoError(t, err) + assert.True(t, syncDone, "initial sync should be marked done when flag is set") +} + +func TestHandleMappingStream_NoSyncFlagDoesNotMarkDone(t *testing.T) { + checker := health.NewChecker(nil, nil) + s := &Server{ + Logger: log.StandardLogger(), + healthChecker: checker, + routerReady: closedChan(), + } + + stream := &mockMappingStream{ + messages: []*proto.GetMappingUpdateResponse{ + {}, // no sync flag + }, + } + + syncDone := false + err := s.handleMappingStream(context.Background(), stream, &syncDone) + assert.NoError(t, err) + assert.False(t, syncDone, "initial sync should not be marked done without flag") +} + +func TestHandleMappingStream_NilHealthChecker(t *testing.T) { + s := &Server{ + Logger: log.StandardLogger(), + routerReady: closedChan(), + } + + stream := &mockMappingStream{ + messages: []*proto.GetMappingUpdateResponse{ + {InitialSyncComplete: true}, + }, + } + + syncDone := false + err := s.handleMappingStream(context.Background(), stream, &syncDone) + assert.NoError(t, err) + assert.True(t, syncDone, "sync done flag should be set even without health checker") +} diff --git a/proxy/internal/accesslog/logger.go b/proxy/internal/accesslog/logger.go new file mode 100644 index 000000000..3ed3275b5 --- /dev/null +++ b/proxy/internal/accesslog/logger.go @@ -0,0 +1,378 @@ +package accesslog + +import ( + "context" + "net/netip" + "sync" + "sync/atomic" + "time" + + "github.com/rs/xid" + log "github.com/sirupsen/logrus" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/netbirdio/netbird/proxy/auth" + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/shared/management/proto" +) + +const ( + requestThreshold = 10000 // Log every 10k requests + bytesThreshold = 1024 * 1024 * 1024 // Log every 1GB + usageCleanupPeriod = 1 * time.Hour // Clean up stale counters every hour + usageInactiveWindow = 24 * time.Hour // Consider domain inactive if no traffic for 24 hours + logSendTimeout = 10 * time.Second + + // denyCooldown is the min interval between deny log entries per service+reason + // to prevent flooding from denied connections (e.g. UDP packets from blocked IPs). + denyCooldown = 10 * time.Second + + // maxDenyBuckets caps tracked deny rate-limit entries to bound memory under DDoS. + maxDenyBuckets = 10000 + + // maxLogWorkers caps concurrent gRPC send goroutines. + maxLogWorkers = 4096 +) + +type domainUsage struct { + requestCount int64 + requestStartTime time.Time + + bytesTransferred int64 + bytesStartTime time.Time + + lastActivity time.Time // Track last activity for cleanup +} + +type gRPCClient interface { + SendAccessLog(ctx context.Context, in *proto.SendAccessLogRequest, opts ...grpc.CallOption) (*proto.SendAccessLogResponse, error) +} + +// denyBucketKey identifies a rate-limited deny log stream. +type denyBucketKey struct { + ServiceID types.ServiceID + Reason string +} + +// denyBucket tracks rate-limited deny log entries. +type denyBucket struct { + lastLogged time.Time + suppressed int64 +} + +// Logger sends access log entries to the management server via gRPC. +type Logger struct { + client gRPCClient + logger *log.Logger + trustedProxies []netip.Prefix + + usageMux sync.Mutex + domainUsage map[string]*domainUsage + + denyMu sync.Mutex + denyBuckets map[denyBucketKey]*denyBucket + + logSem chan struct{} + cleanupCancel context.CancelFunc + dropped atomic.Int64 +} + +// NewLogger creates a new access log Logger. The trustedProxies parameter +// configures which upstream proxy IP ranges are trusted for extracting +// the real client IP from X-Forwarded-For headers. +func NewLogger(client gRPCClient, logger *log.Logger, trustedProxies []netip.Prefix) *Logger { + if logger == nil { + logger = log.StandardLogger() + } + + ctx, cancel := context.WithCancel(context.Background()) + l := &Logger{ + client: client, + logger: logger, + trustedProxies: trustedProxies, + domainUsage: make(map[string]*domainUsage), + denyBuckets: make(map[denyBucketKey]*denyBucket), + logSem: make(chan struct{}, maxLogWorkers), + cleanupCancel: cancel, + } + + // Start background cleanup routine + go l.cleanupStaleUsage(ctx) + + return l +} + +// Close stops the cleanup routine. Should be called during graceful shutdown. +func (l *Logger) Close() { + if l.cleanupCancel != nil { + l.cleanupCancel() + } +} + +type logEntry struct { + ID string + AccountID types.AccountID + ServiceID types.ServiceID + Host string + Path string + DurationMs int64 + Method string + ResponseCode int32 + SourceIP netip.Addr + AuthMechanism string + UserID string + AuthSuccess bool + BytesUpload int64 + BytesDownload int64 + Protocol Protocol +} + +// Protocol identifies the transport protocol of an access log entry. +type Protocol string + +const ( + ProtocolHTTP Protocol = "http" + ProtocolTCP Protocol = "tcp" + ProtocolUDP Protocol = "udp" + ProtocolTLS Protocol = "tls" +) + +// L4Entry holds the data for a layer-4 (TCP/UDP) access log entry. +type L4Entry struct { + AccountID types.AccountID + ServiceID types.ServiceID + Protocol Protocol + Host string // SNI hostname or listen address + SourceIP netip.Addr + DurationMs int64 + BytesUpload int64 + BytesDownload int64 + // DenyReason, when non-empty, indicates the connection was denied. + // Values match the HTTP auth mechanism strings: "ip_restricted", + // "country_restricted", "geo_unavailable". + DenyReason string +} + +// LogL4 sends an access log entry for a layer-4 connection (TCP or UDP). +// The call is non-blocking: the gRPC send happens in a background goroutine. +func (l *Logger) LogL4(entry L4Entry) { + le := logEntry{ + ID: xid.New().String(), + AccountID: entry.AccountID, + ServiceID: entry.ServiceID, + Protocol: entry.Protocol, + Host: entry.Host, + SourceIP: entry.SourceIP, + DurationMs: entry.DurationMs, + BytesUpload: entry.BytesUpload, + BytesDownload: entry.BytesDownload, + } + if entry.DenyReason != "" { + if !l.allowDenyLog(entry.ServiceID, entry.DenyReason) { + return + } + le.AuthMechanism = entry.DenyReason + le.AuthSuccess = false + } + l.log(le) + l.trackUsage(entry.Host, entry.BytesUpload+entry.BytesDownload) +} + +// allowDenyLog rate-limits deny log entries per service+reason combination. +func (l *Logger) allowDenyLog(serviceID types.ServiceID, reason string) bool { + key := denyBucketKey{ServiceID: serviceID, Reason: reason} + now := time.Now() + + l.denyMu.Lock() + defer l.denyMu.Unlock() + + b, ok := l.denyBuckets[key] + if !ok { + if len(l.denyBuckets) >= maxDenyBuckets { + return false + } + l.denyBuckets[key] = &denyBucket{lastLogged: now} + return true + } + + if now.Sub(b.lastLogged) >= denyCooldown { + if b.suppressed > 0 { + l.logger.Debugf("access restriction: suppressed %d deny log entries for %s (%s)", b.suppressed, serviceID, reason) + } + b.lastLogged = now + b.suppressed = 0 + return true + } + + b.suppressed++ + return false +} + +func (l *Logger) log(entry logEntry) { + // Fire off the log request in a separate routine. + // This increases the possibility of losing a log message + // (although it should still get logged in the event of an error), + // but it will reduce latency returning the request in the + // middleware. + // There is also a chance that log messages will arrive at + // the server out of order; however, the timestamp should + // allow for resolving that on the server. + now := timestamppb.Now() + select { + case l.logSem <- struct{}{}: + default: + total := l.dropped.Add(1) + l.logger.Debugf("access log send dropped: worker limit reached (total dropped: %d)", total) + return + } + go func() { + defer func() { <-l.logSem }() + logCtx, cancel := context.WithTimeout(context.Background(), logSendTimeout) + defer cancel() + // Only OIDC sessions have a meaningful user identity. + if entry.AuthMechanism != auth.MethodOIDC.String() { + entry.UserID = "" + } + + var sourceIP string + if entry.SourceIP.IsValid() { + sourceIP = entry.SourceIP.String() + } + + if _, err := l.client.SendAccessLog(logCtx, &proto.SendAccessLogRequest{ + Log: &proto.AccessLog{ + LogId: entry.ID, + AccountId: string(entry.AccountID), + Timestamp: now, + ServiceId: string(entry.ServiceID), + Host: entry.Host, + Path: entry.Path, + DurationMs: entry.DurationMs, + Method: entry.Method, + ResponseCode: entry.ResponseCode, + SourceIp: sourceIP, + AuthMechanism: entry.AuthMechanism, + UserId: entry.UserID, + AuthSuccess: entry.AuthSuccess, + BytesUpload: entry.BytesUpload, + BytesDownload: entry.BytesDownload, + Protocol: string(entry.Protocol), + }, + }); err != nil { + l.logger.WithFields(log.Fields{ + "service_id": entry.ServiceID, + "host": entry.Host, + "path": entry.Path, + "duration": entry.DurationMs, + "method": entry.Method, + "response_code": entry.ResponseCode, + "source_ip": sourceIP, + "auth_mechanism": entry.AuthMechanism, + "user_id": entry.UserID, + "auth_success": entry.AuthSuccess, + "error": err, + }).Error("Error sending access log on gRPC connection") + } + }() +} + +// trackUsage records request and byte counts per domain, logging when thresholds are hit. +func (l *Logger) trackUsage(domain string, bytesTransferred int64) { + if domain == "" { + return + } + + l.usageMux.Lock() + defer l.usageMux.Unlock() + + now := time.Now() + usage, exists := l.domainUsage[domain] + if !exists { + usage = &domainUsage{ + requestStartTime: now, + bytesStartTime: now, + lastActivity: now, + } + l.domainUsage[domain] = usage + } + + usage.lastActivity = now + + usage.requestCount++ + if usage.requestCount >= requestThreshold { + elapsed := time.Since(usage.requestStartTime) + l.logger.WithFields(log.Fields{ + "domain": domain, + "requests": usage.requestCount, + "duration": elapsed.String(), + }).Infof("domain %s had %d requests over %s", domain, usage.requestCount, elapsed) + + usage.requestCount = 0 + usage.requestStartTime = now + } + + usage.bytesTransferred += bytesTransferred + if usage.bytesTransferred >= bytesThreshold { + elapsed := time.Since(usage.bytesStartTime) + bytesInGB := float64(usage.bytesTransferred) / (1024 * 1024 * 1024) + l.logger.WithFields(log.Fields{ + "domain": domain, + "bytes": usage.bytesTransferred, + "bytes_gb": bytesInGB, + "duration": elapsed.String(), + }).Infof("domain %s transferred %.2f GB over %s", domain, bytesInGB, elapsed) + + usage.bytesTransferred = 0 + usage.bytesStartTime = now + } +} + +// cleanupStaleUsage removes usage and deny-rate-limit entries that have been inactive. +func (l *Logger) cleanupStaleUsage(ctx context.Context) { + ticker := time.NewTicker(usageCleanupPeriod) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + now := time.Now() + l.cleanupDomainUsage(now) + l.cleanupDenyBuckets(now) + } + } +} + +func (l *Logger) cleanupDomainUsage(now time.Time) { + l.usageMux.Lock() + defer l.usageMux.Unlock() + + removed := 0 + for domain, usage := range l.domainUsage { + if now.Sub(usage.lastActivity) > usageInactiveWindow { + delete(l.domainUsage, domain) + removed++ + } + } + if removed > 0 { + l.logger.Debugf("cleaned up %d stale domain usage entries", removed) + } +} + +func (l *Logger) cleanupDenyBuckets(now time.Time) { + l.denyMu.Lock() + defer l.denyMu.Unlock() + + removed := 0 + for key, bucket := range l.denyBuckets { + if now.Sub(bucket.lastLogged) > usageInactiveWindow { + delete(l.denyBuckets, key) + removed++ + } + } + if removed > 0 { + l.logger.Debugf("cleaned up %d stale deny rate-limit entries", removed) + } +} diff --git a/proxy/internal/accesslog/middleware.go b/proxy/internal/accesslog/middleware.go new file mode 100644 index 000000000..81c790b17 --- /dev/null +++ b/proxy/internal/accesslog/middleware.go @@ -0,0 +1,94 @@ +package accesslog + +import ( + "net" + "net/http" + "strings" + "time" + + "github.com/rs/xid" + + "github.com/netbirdio/netbird/proxy/internal/proxy" + "github.com/netbirdio/netbird/proxy/internal/responsewriter" + "github.com/netbirdio/netbird/proxy/web" +) + +// Middleware wraps an HTTP handler to log access entries and resolve client IPs. +func (l *Logger) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Skip logging for internal proxy assets (CSS, JS, etc.) + if strings.HasPrefix(r.URL.Path, web.PathPrefix+"/") { + next.ServeHTTP(w, r) + return + } + + // Generate request ID early so it can be used by error pages and log correlation. + requestID := xid.New().String() + + l.logger.Debugf("request: request_id=%s method=%s host=%s path=%s", requestID, r.Method, r.Host, r.URL.Path) + + // Use a response writer wrapper so we can access the status code later. + sw := &statusWriter{ + PassthroughWriter: responsewriter.New(w), + status: http.StatusOK, + } + + var bytesRead int64 + if r.Body != nil { + r.Body = &bodyCounter{ + ReadCloser: r.Body, + bytesRead: &bytesRead, + } + } + + // Resolve the source IP using trusted proxy configuration before passing + // the request on, as the proxy will modify forwarding headers. + sourceIp := extractSourceIP(r, l.trustedProxies) + + // Create a mutable struct to capture data from downstream handlers. + // We pass a pointer in the context - the pointer itself flows down immutably, + // but the struct it points to can be mutated by inner handlers. + capturedData := proxy.NewCapturedData(requestID) + capturedData.SetClientIP(sourceIp) + + ctx := proxy.WithCapturedData(r.Context(), capturedData) + + start := time.Now() + next.ServeHTTP(sw, r.WithContext(ctx)) + duration := time.Since(start) + + host, _, err := net.SplitHostPort(r.Host) + if err != nil { + // Fallback to just using the full host value. + host = r.Host + } + + bytesUpload := bytesRead + bytesDownload := sw.bytesWritten + + entry := logEntry{ + ID: requestID, + ServiceID: capturedData.GetServiceID(), + AccountID: capturedData.GetAccountID(), + Host: host, + Path: r.URL.Path, + DurationMs: duration.Milliseconds(), + Method: r.Method, + ResponseCode: int32(sw.status), + SourceIP: sourceIp, + AuthMechanism: capturedData.GetAuthMethod(), + UserID: capturedData.GetUserID(), + AuthSuccess: sw.status != http.StatusUnauthorized && sw.status != http.StatusForbidden, + BytesUpload: bytesUpload, + BytesDownload: bytesDownload, + Protocol: ProtocolHTTP, + } + l.logger.Debugf("response: request_id=%s method=%s host=%s path=%s status=%d duration=%dms source=%s origin=%s service=%s account=%s", + requestID, r.Method, host, r.URL.Path, sw.status, duration.Milliseconds(), sourceIp, capturedData.GetOrigin(), capturedData.GetServiceID(), capturedData.GetAccountID()) + + l.log(entry) + + // Track usage for cost monitoring (upload + download) by domain + l.trackUsage(host, bytesUpload+bytesDownload) + }) +} diff --git a/proxy/internal/accesslog/requestip.go b/proxy/internal/accesslog/requestip.go new file mode 100644 index 000000000..30c483fd9 --- /dev/null +++ b/proxy/internal/accesslog/requestip.go @@ -0,0 +1,16 @@ +package accesslog + +import ( + "net/http" + "net/netip" + + "github.com/netbirdio/netbird/proxy/internal/proxy" +) + +// extractSourceIP resolves the real client IP from the request using trusted +// proxy configuration. When trustedProxies is non-empty and the direct +// connection is from a trusted source, it walks X-Forwarded-For right-to-left +// skipping trusted IPs. Otherwise it returns RemoteAddr directly. +func extractSourceIP(r *http.Request, trustedProxies []netip.Prefix) netip.Addr { + return proxy.ResolveClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), trustedProxies) +} diff --git a/proxy/internal/accesslog/statuswriter.go b/proxy/internal/accesslog/statuswriter.go new file mode 100644 index 000000000..24f7b35e9 --- /dev/null +++ b/proxy/internal/accesslog/statuswriter.go @@ -0,0 +1,39 @@ +package accesslog + +import ( + "io" + + "github.com/netbirdio/netbird/proxy/internal/responsewriter" +) + +// statusWriter captures the HTTP status code and bytes written from responses. +// It embeds responsewriter.PassthroughWriter which handles all the optional +// interfaces (Hijacker, Flusher, Pusher) automatically. +type statusWriter struct { + *responsewriter.PassthroughWriter + status int + bytesWritten int64 +} + +func (w *statusWriter) WriteHeader(status int) { + w.status = status + w.PassthroughWriter.WriteHeader(status) +} + +func (w *statusWriter) Write(b []byte) (int, error) { + n, err := w.PassthroughWriter.Write(b) + w.bytesWritten += int64(n) + return n, err +} + +// bodyCounter wraps an io.ReadCloser and counts bytes read from the request body. +type bodyCounter struct { + io.ReadCloser + bytesRead *int64 +} + +func (bc *bodyCounter) Read(p []byte) (int, error) { + n, err := bc.ReadCloser.Read(p) + *bc.bytesRead += int64(n) + return n, err +} diff --git a/proxy/internal/acme/locker.go b/proxy/internal/acme/locker.go new file mode 100644 index 000000000..2f0f18885 --- /dev/null +++ b/proxy/internal/acme/locker.go @@ -0,0 +1,102 @@ +package acme + +import ( + "context" + "path/filepath" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/proxy/internal/flock" + "github.com/netbirdio/netbird/proxy/internal/k8s" +) + +// certLocker provides distributed mutual exclusion for certificate operations. +// Implementations must be safe for concurrent use from multiple goroutines. +type certLocker interface { + // Lock acquires an exclusive lock for the given domain. + // It blocks until the lock is acquired, the context is cancelled, or an + // unrecoverable error occurs. The returned function releases the lock; + // callers must call it exactly once when the critical section is complete. + Lock(ctx context.Context, domain string) (unlock func(), err error) +} + +// CertLockMethod controls how ACME certificate locks are coordinated. +type CertLockMethod string + +const ( + // CertLockAuto detects the environment and selects k8s-lease if running + // in a Kubernetes pod, otherwise flock. + CertLockAuto CertLockMethod = "auto" + // CertLockFlock uses advisory file locks via flock(2). + CertLockFlock CertLockMethod = "flock" + // CertLockK8sLease uses Kubernetes coordination Leases. + CertLockK8sLease CertLockMethod = "k8s-lease" +) + +func newCertLocker(method CertLockMethod, certDir string, logger *log.Logger) certLocker { + if logger == nil { + logger = log.StandardLogger() + } + + if method == "" || method == CertLockAuto { + if k8s.InCluster() { + method = CertLockK8sLease + } else { + method = CertLockFlock + } + logger.Infof("auto-detected cert lock method: %s", method) + } + + switch method { + case CertLockK8sLease: + locker, err := newK8sLeaseLocker(logger) + if err != nil { + logger.Warnf("create k8s lease locker, falling back to flock: %v", err) + return newFlockLocker(certDir, logger) + } + logger.Infof("using k8s lease locker in namespace %s", locker.client.Namespace()) + return locker + default: + logger.Infof("using flock cert locker in %s", certDir) + return newFlockLocker(certDir, logger) + } +} + +type flockLocker struct { + certDir string + logger *log.Logger +} + +func newFlockLocker(certDir string, logger *log.Logger) *flockLocker { + if logger == nil { + logger = log.StandardLogger() + } + return &flockLocker{certDir: certDir, logger: logger} +} + +// Lock acquires an advisory file lock for the given domain. +func (l *flockLocker) Lock(ctx context.Context, domain string) (func(), error) { + lockPath := filepath.Join(l.certDir, domain+".lock") + lockFile, err := flock.Lock(ctx, lockPath) + if err != nil { + return nil, err + } + + // nil lockFile means locking is not supported (non-unix). + if lockFile == nil { + return func() { /* no-op: locking unsupported on this platform */ }, nil + } + + return func() { + if err := flock.Unlock(lockFile); err != nil { + l.logger.Debugf("release cert lock for domain %q: %v", domain, err) + } + }, nil +} + +type noopLocker struct{} + +// Lock is a no-op that always succeeds immediately. +func (noopLocker) Lock(context.Context, string) (func(), error) { + return func() { /* no-op: locker disabled */ }, nil +} diff --git a/proxy/internal/acme/locker_k8s.go b/proxy/internal/acme/locker_k8s.go new file mode 100644 index 000000000..a3f8043e6 --- /dev/null +++ b/proxy/internal/acme/locker_k8s.go @@ -0,0 +1,197 @@ +package acme + +import ( + "context" + "errors" + "fmt" + "os" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/proxy/internal/k8s" +) + +const ( + // leaseDurationSec is the Kubernetes Lease TTL. If the holder crashes without + // releasing the lock, other replicas must wait this long before taking over. + // This is intentionally generous: in the worst case two replicas may both + // issue an ACME request for the same domain, which is harmless (the CA + // deduplicates and the cache converges). + leaseDurationSec = 300 + retryBaseBackoff = 500 * time.Millisecond + retryMaxBackoff = 10 * time.Second +) + +type k8sLeaseLocker struct { + client *k8s.LeaseClient + identity string + logger *log.Logger +} + +func newK8sLeaseLocker(logger *log.Logger) (*k8sLeaseLocker, error) { + client, err := k8s.NewLeaseClient() + if err != nil { + return nil, fmt.Errorf("create k8s lease client: %w", err) + } + + identity, err := os.Hostname() + if err != nil { + return nil, fmt.Errorf("get hostname: %w", err) + } + + return &k8sLeaseLocker{ + client: client, + identity: identity, + logger: logger, + }, nil +} + +// Lock acquires a Kubernetes Lease for the given domain using optimistic +// concurrency. It retries with exponential backoff until the lease is +// acquired or the context is cancelled. +func (l *k8sLeaseLocker) Lock(ctx context.Context, domain string) (func(), error) { + leaseName := k8s.LeaseNameForDomain(domain) + backoff := retryBaseBackoff + + for { + acquired, err := l.tryAcquire(ctx, leaseName, domain) + if err != nil { + return nil, fmt.Errorf("acquire lease %s for %q: %w", leaseName, domain, err) + } + if acquired { + l.logger.Debugf("k8s lease %s acquired for domain %q", leaseName, domain) + return l.unlockFunc(leaseName, domain), nil + } + + l.logger.Debugf("k8s lease %s held by another replica, retrying in %s", leaseName, backoff) + + timer := time.NewTimer(backoff) + select { + case <-ctx.Done(): + timer.Stop() + return nil, ctx.Err() + case <-timer.C: + } + + backoff *= 2 + if backoff > retryMaxBackoff { + backoff = retryMaxBackoff + } + } +} + +// tryAcquire attempts to create or take over a Lease. Returns (true, nil) +// on success, (false, nil) if the lease is held and not stale, or an error. +func (l *k8sLeaseLocker) tryAcquire(ctx context.Context, name, domain string) (bool, error) { + existing, err := l.client.Get(ctx, name) + if err != nil { + return false, err + } + + now := k8s.MicroTime{Time: time.Now().UTC()} + dur := int32(leaseDurationSec) + + if existing == nil { + lease := &k8s.Lease{ + Metadata: k8s.LeaseMetadata{ + Name: name, + Annotations: map[string]string{ + "netbird.io/domain": domain, + }, + }, + Spec: k8s.LeaseSpec{ + HolderIdentity: &l.identity, + LeaseDurationSeconds: &dur, + AcquireTime: &now, + RenewTime: &now, + }, + } + + if _, err := l.client.Create(ctx, lease); errors.Is(err, k8s.ErrConflict) { + return false, nil + } else if err != nil { + return false, err + } + return true, nil + } + + if !l.canTakeover(existing) { + return false, nil + } + + existing.Spec.HolderIdentity = &l.identity + existing.Spec.LeaseDurationSeconds = &dur + existing.Spec.AcquireTime = &now + existing.Spec.RenewTime = &now + + if _, err := l.client.Update(ctx, existing); errors.Is(err, k8s.ErrConflict) { + return false, nil + } else if err != nil { + return false, err + } + return true, nil +} + +// canTakeover returns true if the lease is free (no holder) or stale +// (renewTime + leaseDuration has passed). +func (l *k8sLeaseLocker) canTakeover(lease *k8s.Lease) bool { + holder := lease.Spec.HolderIdentity + if holder == nil || *holder == "" { + return true + } + + // We already hold it (e.g. from a previous crashed attempt). + if *holder == l.identity { + return true + } + + if lease.Spec.RenewTime == nil || lease.Spec.LeaseDurationSeconds == nil { + return true + } + + expiry := lease.Spec.RenewTime.Add(time.Duration(*lease.Spec.LeaseDurationSeconds) * time.Second) + if time.Now().After(expiry) { + l.logger.Infof("k8s lease %s held by %q is stale (expired %s ago), taking over", + lease.Metadata.Name, *holder, time.Since(expiry).Round(time.Second)) + return true + } + + return false +} + +// unlockFunc returns a closure that releases the lease by clearing the holder. +func (l *k8sLeaseLocker) unlockFunc(name, domain string) func() { + return func() { + // Use a fresh context: the parent may already be cancelled. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Re-GET to get current resourceVersion (ours may be stale if + // the lock was held for a long time and something updated it). + current, err := l.client.Get(ctx, name) + if err != nil { + l.logger.Debugf("release k8s lease %s for %q: get: %v", name, domain, err) + return + } + if current == nil { + return + } + + // Only clear if we're still the holder. + if current.Spec.HolderIdentity == nil || *current.Spec.HolderIdentity != l.identity { + l.logger.Debugf("k8s lease %s for %q: holder changed to %v, skip release", + name, domain, current.Spec.HolderIdentity) + return + } + + empty := "" + current.Spec.HolderIdentity = &empty + current.Spec.AcquireTime = nil + current.Spec.RenewTime = nil + + if _, err := l.client.Update(ctx, current); err != nil { + l.logger.Debugf("release k8s lease %s for %q: update: %v", name, domain, err) + } + } +} diff --git a/proxy/internal/acme/locker_test.go b/proxy/internal/acme/locker_test.go new file mode 100644 index 000000000..39245df0c --- /dev/null +++ b/proxy/internal/acme/locker_test.go @@ -0,0 +1,65 @@ +package acme + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFlockLockerRoundTrip(t *testing.T) { + dir := t.TempDir() + locker := newFlockLocker(dir, nil) + + unlock, err := locker.Lock(context.Background(), "example.com") + require.NoError(t, err) + require.NotNil(t, unlock) + + // Lock file should exist. + assert.FileExists(t, filepath.Join(dir, "example.com.lock")) + + unlock() +} + +func TestNoopLocker(t *testing.T) { + locker := noopLocker{} + unlock, err := locker.Lock(context.Background(), "example.com") + require.NoError(t, err) + require.NotNil(t, unlock) + unlock() +} + +func TestNewCertLockerDefaultsToFlock(t *testing.T) { + dir := t.TempDir() + + // t.Setenv registers cleanup to restore the original value. + // os.Unsetenv is needed because the production code uses LookupEnv, + // which distinguishes "empty" from "not set". + t.Setenv("KUBERNETES_SERVICE_HOST", "") + os.Unsetenv("KUBERNETES_SERVICE_HOST") + locker := newCertLocker(CertLockAuto, dir, nil) + + _, ok := locker.(*flockLocker) + assert.True(t, ok, "auto without k8s env should select flockLocker") +} + +func TestNewCertLockerExplicitFlock(t *testing.T) { + dir := t.TempDir() + locker := newCertLocker(CertLockFlock, dir, nil) + + _, ok := locker.(*flockLocker) + assert.True(t, ok, "explicit flock should select flockLocker") +} + +func TestNewCertLockerK8sFallsBackToFlock(t *testing.T) { + dir := t.TempDir() + + // k8s-lease without SA files should fall back to flock. + locker := newCertLocker(CertLockK8sLease, dir, nil) + + _, ok := locker.(*flockLocker) + assert.True(t, ok, "k8s-lease without SA should fall back to flockLocker") +} diff --git a/proxy/internal/acme/manager.go b/proxy/internal/acme/manager.go new file mode 100644 index 000000000..a4a220ed7 --- /dev/null +++ b/proxy/internal/acme/manager.go @@ -0,0 +1,643 @@ +package acme + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/asn1" + "encoding/base64" + "encoding/binary" + "encoding/pem" + "fmt" + "math/rand/v2" + "net" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/acme" + "golang.org/x/crypto/acme/autocert" + + "github.com/netbirdio/netbird/proxy/internal/certwatch" + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/shared/management/domain" +) + +// OID for the SCT list extension (1.3.6.1.4.1.11129.2.4.2) +var oidSCTList = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 11129, 2, 4, 2} + +type certificateNotifier interface { + NotifyCertificateIssued(ctx context.Context, accountID types.AccountID, serviceID types.ServiceID, domain string) error +} + +type domainState int + +const ( + domainPending domainState = iota + domainReady + domainFailed +) + +type domainInfo struct { + accountID types.AccountID + serviceID types.ServiceID + state domainState + err string +} + +type metricsRecorder interface { + RecordCertificateIssuance(duration time.Duration) +} + +// wildcardEntry maps a domain suffix (e.g. ".example.com") to a certwatch +// watcher that hot-reloads the corresponding wildcard certificate from disk. +type wildcardEntry struct { + suffix string // e.g. ".example.com" + pattern string // e.g. "*.example.com" + watcher *certwatch.Watcher +} + +// ManagerConfig holds the configuration values for the ACME certificate manager. +type ManagerConfig struct { + // CertDir is the directory used for caching ACME certificates. + CertDir string + // ACMEURL is the ACME directory URL (e.g. Let's Encrypt). + ACMEURL string + // EABKID and EABHMACKey are optional External Account Binding credentials + // required by some CAs (e.g. ZeroSSL). EABHMACKey is the base64 + // URL-encoded string provided by the CA. + EABKID string + EABHMACKey string + // LockMethod controls the cross-replica coordination strategy. + LockMethod CertLockMethod + // WildcardDir is an optional path to a directory containing wildcard + // certificate pairs (.crt / .key). Wildcard patterns are + // extracted from the certificates' SAN lists. Domains matching a + // wildcard are served from disk; all others go through ACME. + WildcardDir string +} + +// Manager wraps autocert.Manager with domain tracking and cross-replica +// coordination via a pluggable locking strategy. The locker prevents +// duplicate ACME requests when multiple replicas share a certificate cache. +type Manager struct { + *autocert.Manager + + certDir string + locker certLocker + mu sync.RWMutex + domains map[domain.Domain]*domainInfo + + // wildcards holds all loaded wildcard certificates, keyed by suffix. + wildcards []wildcardEntry + + certNotifier certificateNotifier + logger *log.Logger + metrics metricsRecorder +} + +// NewManager creates a new ACME certificate manager. +func NewManager(cfg ManagerConfig, notifier certificateNotifier, logger *log.Logger, metrics metricsRecorder) (*Manager, error) { + if logger == nil { + logger = log.StandardLogger() + } + mgr := &Manager{ + certDir: cfg.CertDir, + locker: newCertLocker(cfg.LockMethod, cfg.CertDir, logger), + domains: make(map[domain.Domain]*domainInfo), + certNotifier: notifier, + logger: logger, + metrics: metrics, + } + + if cfg.WildcardDir != "" { + entries, err := loadWildcardDir(cfg.WildcardDir, logger) + if err != nil { + return nil, fmt.Errorf("load wildcard certificates from %q: %w", cfg.WildcardDir, err) + } + mgr.wildcards = entries + } + + var eab *acme.ExternalAccountBinding + if cfg.EABKID != "" && cfg.EABHMACKey != "" { + decodedKey, err := base64.RawURLEncoding.DecodeString(cfg.EABHMACKey) + if err != nil { + logger.Errorf("failed to decode EAB HMAC key: %v", err) + } else { + eab = &acme.ExternalAccountBinding{ + KID: cfg.EABKID, + Key: decodedKey, + } + logger.Infof("configured External Account Binding with KID: %s", cfg.EABKID) + } + } + + mgr.Manager = &autocert.Manager{ + Prompt: autocert.AcceptTOS, + HostPolicy: mgr.hostPolicy, + Cache: autocert.DirCache(cfg.CertDir), + ExternalAccountBinding: eab, + Client: &acme.Client{ + DirectoryURL: cfg.ACMEURL, + }, + } + return mgr, nil +} + +// WatchWildcards starts watching all wildcard certificate files for changes. +// It blocks until ctx is cancelled. It is a no-op if no wildcards are loaded. +func (mgr *Manager) WatchWildcards(ctx context.Context) { + if len(mgr.wildcards) == 0 { + return + } + seen := make(map[*certwatch.Watcher]struct{}) + var wg sync.WaitGroup + for i := range mgr.wildcards { + w := mgr.wildcards[i].watcher + if _, ok := seen[w]; ok { + continue + } + seen[w] = struct{}{} + wg.Add(1) + go func() { + defer wg.Done() + w.Watch(ctx) + }() + } + wg.Wait() +} + +// loadWildcardDir scans dir for .crt files, pairs each with a matching .key +// file, loads them, and extracts wildcard SANs (*.example.com) to build +// the suffix lookup entries. +func loadWildcardDir(dir string, logger *log.Logger) ([]wildcardEntry, error) { + crtFiles, err := filepath.Glob(filepath.Join(dir, "*.crt")) + if err != nil { + return nil, fmt.Errorf("glob certificate files: %w", err) + } + + if len(crtFiles) == 0 { + return nil, fmt.Errorf("no .crt files found in %s", dir) + } + + var entries []wildcardEntry + + for _, crtPath := range crtFiles { + base := strings.TrimSuffix(filepath.Base(crtPath), ".crt") + keyPath := filepath.Join(dir, base+".key") + if _, err := os.Stat(keyPath); err != nil { + logger.Warnf("skipping %s: no matching key file %s", crtPath, keyPath) + continue + } + + watcher, err := certwatch.NewWatcher(crtPath, keyPath, logger) + if err != nil { + logger.Warnf("skipping %s: %v", crtPath, err) + continue + } + + leaf := watcher.Leaf() + if leaf == nil { + logger.Warnf("skipping %s: no parsed leaf certificate", crtPath) + continue + } + + for _, san := range leaf.DNSNames { + suffix, ok := parseWildcard(san) + if !ok { + continue + } + entries = append(entries, wildcardEntry{ + suffix: suffix, + pattern: san, + watcher: watcher, + }) + logger.Infof("wildcard certificate loaded: %s (from %s)", san, filepath.Base(crtPath)) + } + } + + if len(entries) == 0 { + return nil, fmt.Errorf("no wildcard SANs (*.example.com) found in certificates in %s", dir) + } + + return entries, nil +} + +// parseWildcard validates a wildcard domain pattern like "*.example.com" +// and returns the suffix ".example.com" for matching. +func parseWildcard(pattern string) (suffix string, ok bool) { + if !strings.HasPrefix(pattern, "*.") { + return "", false + } + parent := pattern[1:] // ".example.com" + if strings.Count(parent, ".") < 1 { + return "", false + } + return strings.ToLower(parent), true +} + +// findWildcardEntry returns the wildcard entry that covers host, or nil. +func (mgr *Manager) findWildcardEntry(host string) *wildcardEntry { + if len(mgr.wildcards) == 0 { + return nil + } + host = strings.ToLower(host) + for i := range mgr.wildcards { + e := &mgr.wildcards[i] + if !strings.HasSuffix(host, e.suffix) { + continue + } + // Single-level match: prefix before suffix must have no dots. + prefix := strings.TrimSuffix(host, e.suffix) + if len(prefix) > 0 && !strings.Contains(prefix, ".") { + return e + } + } + return nil +} + +// WildcardPatterns returns the wildcard patterns that are currently loaded. +func (mgr *Manager) WildcardPatterns() []string { + patterns := make([]string, len(mgr.wildcards)) + for i, e := range mgr.wildcards { + patterns[i] = e.pattern + } + slices.Sort(patterns) + return patterns +} + +func (mgr *Manager) hostPolicy(_ context.Context, host string) error { + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + mgr.mu.RLock() + _, exists := mgr.domains[domain.Domain(host)] + mgr.mu.RUnlock() + if !exists { + return fmt.Errorf("unknown domain %q", host) + } + return nil +} + +// GetCertificate returns the TLS certificate for the given ClientHello. +// If the requested domain matches a loaded wildcard, the static wildcard +// certificate is returned. Otherwise, the ACME autocert manager handles +// the request. +func (mgr *Manager) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + if e := mgr.findWildcardEntry(hello.ServerName); e != nil { + return e.watcher.GetCertificate(hello) + } + return mgr.Manager.GetCertificate(hello) +} + +// AddDomain registers a domain for certificate management. Domains that +// match a loaded wildcard are marked ready immediately (they use the +// static wildcard certificate) and the method returns true. All other +// domains go through ACME prefetch and the method returns false. +// +// When AddDomain returns true the caller is responsible for sending any +// certificate-ready notifications after the surrounding operation (e.g. +// mapping update) has committed successfully. +func (mgr *Manager) AddDomain(d domain.Domain, accountID types.AccountID, serviceID types.ServiceID) (wildcardHit bool) { + name := d.PunycodeString() + if e := mgr.findWildcardEntry(name); e != nil { + mgr.mu.Lock() + mgr.domains[d] = &domainInfo{ + accountID: accountID, + serviceID: serviceID, + state: domainReady, + } + mgr.mu.Unlock() + mgr.logger.Debugf("domain %q matches wildcard %q, using static certificate", name, e.pattern) + return true + } + + mgr.mu.Lock() + mgr.domains[d] = &domainInfo{ + accountID: accountID, + serviceID: serviceID, + state: domainPending, + } + mgr.mu.Unlock() + + go mgr.prefetchCertificate(d) + return false +} + +// prefetchCertificate proactively triggers certificate generation for a domain. +// It acquires a distributed lock to prevent multiple replicas from issuing +// duplicate ACME requests. The second replica will block until the first +// finishes, then find the certificate in the cache. +// ACME and periodic disk reads race; whichever produces a valid certificate +// first wins. This handles cases where locking is unreliable and another +// replica already wrote the cert to the shared cache. +func (mgr *Manager) prefetchCertificate(d domain.Domain) { + time.Sleep(time.Duration(rand.IntN(200)) * time.Millisecond) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + name := d.PunycodeString() + + mgr.logger.Infof("acquiring cert lock for domain %q", name) + lockStart := time.Now() + unlock, err := mgr.locker.Lock(ctx, name) + if err != nil { + mgr.logger.Warnf("acquire cert lock for domain %q, proceeding without lock: %v", name, err) + } else { + mgr.logger.Infof("acquired cert lock for domain %q in %s", name, time.Since(lockStart)) + defer unlock() + } + + if cert, err := mgr.readCertFromDisk(ctx, name); err == nil { + mgr.logger.Infof("certificate for domain %q already on disk, skipping ACME", name) + mgr.recordAndNotify(ctx, d, name, cert, 0) + return + } + + // Run ACME in a goroutine so we can race it against periodic disk reads. + // autocert uses its own internal context and cannot be cancelled externally. + type acmeResult struct { + cert *tls.Certificate + err error + } + acmeCh := make(chan acmeResult, 1) + hello := &tls.ClientHelloInfo{ServerName: name, Conn: &dummyConn{ctx: ctx}} + go func() { + cert, err := mgr.GetCertificate(hello) + acmeCh <- acmeResult{cert, err} + }() + + start := time.Now() + diskTicker := time.NewTicker(5 * time.Second) + defer diskTicker.Stop() + + for { + select { + case res := <-acmeCh: + elapsed := time.Since(start) + if res.err != nil { + mgr.logger.Warnf("prefetch certificate for domain %q in %s: %v", name, elapsed.String(), res.err) + mgr.setDomainState(d, domainFailed, res.err.Error()) + return + } + mgr.recordAndNotify(ctx, d, name, res.cert, elapsed) + return + + case <-diskTicker.C: + cert, err := mgr.readCertFromDisk(context.Background(), name) + if err != nil { + continue + } + mgr.logger.Infof("certificate for domain %q appeared on disk after %s", name, time.Since(start).Round(time.Millisecond)) + // Drain the ACME goroutine before marking ready — autocert holds + // an internal write lock on certState while ACME is in flight. + go func() { + select { + case <-acmeCh: + default: + } + mgr.recordAndNotify(context.Background(), d, name, cert, 0) + }() + return + + case <-ctx.Done(): + mgr.logger.Warnf("prefetch certificate for domain %q timed out", name) + mgr.setDomainState(d, domainFailed, ctx.Err().Error()) + return + } + } +} + +// readCertFromDisk reads and parses a certificate directly from the autocert +// DirCache, bypassing autocert's internal certState mutex. Safe to call +// concurrently with an in-flight ACME request for the same domain. +func (mgr *Manager) readCertFromDisk(ctx context.Context, name string) (*tls.Certificate, error) { + if mgr.Cache == nil { + return nil, fmt.Errorf("no cache configured") + } + data, err := mgr.Cache.Get(ctx, name) + if err != nil { + return nil, err + } + privBlock, certsPEM := pem.Decode(data) + if privBlock == nil || !strings.Contains(privBlock.Type, "PRIVATE") { + return nil, fmt.Errorf("no private key in cache for %q", name) + } + cert, err := tls.X509KeyPair(certsPEM, pem.EncodeToMemory(privBlock)) + if err != nil { + return nil, fmt.Errorf("parse cached certificate for %q: %w", name, err) + } + if len(cert.Certificate) > 0 { + leaf, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + return nil, fmt.Errorf("parse leaf for %q: %w", name, err) + } + if time.Now().After(leaf.NotAfter) { + return nil, fmt.Errorf("cached certificate for %q expired at %s", name, leaf.NotAfter) + } + cert.Leaf = leaf + } + return &cert, nil +} + +// recordAndNotify records metrics, marks the domain ready, logs cert details, +// and notifies the cert notifier. +func (mgr *Manager) recordAndNotify(ctx context.Context, d domain.Domain, name string, cert *tls.Certificate, elapsed time.Duration) { + if elapsed > 0 && mgr.metrics != nil { + mgr.metrics.RecordCertificateIssuance(elapsed) + } + mgr.setDomainState(d, domainReady, "") + now := time.Now() + if cert != nil && cert.Leaf != nil { + leaf := cert.Leaf + mgr.logger.Infof("certificate for domain %q ready in %s: serial=%s SANs=%v notBefore=%s, notAfter=%s, now=%s", + name, elapsed.Round(time.Millisecond), + leaf.SerialNumber.Text(16), + leaf.DNSNames, + leaf.NotBefore.UTC().Format(time.RFC3339), + leaf.NotAfter.UTC().Format(time.RFC3339), + now.UTC().Format(time.RFC3339), + ) + mgr.logCertificateDetails(name, leaf, now) + } else { + mgr.logger.Infof("certificate for domain %q ready in %s", name, elapsed.Round(time.Millisecond)) + } + mgr.mu.RLock() + info := mgr.domains[d] + mgr.mu.RUnlock() + if info != nil && mgr.certNotifier != nil { + if err := mgr.certNotifier.NotifyCertificateIssued(ctx, info.accountID, info.serviceID, name); err != nil { + mgr.logger.Warnf("notify certificate ready for domain %q: %v", name, err) + } + } +} + +func (mgr *Manager) setDomainState(d domain.Domain, state domainState, errMsg string) { + mgr.mu.Lock() + defer mgr.mu.Unlock() + if info, ok := mgr.domains[d]; ok { + info.state = state + info.err = errMsg + } +} + +// logCertificateDetails logs certificate validity and SCT timestamps. +func (mgr *Manager) logCertificateDetails(domain string, cert *x509.Certificate, now time.Time) { + if cert.NotBefore.After(now) { + mgr.logger.Warnf("certificate for %q NotBefore is in the future by %v", domain, cert.NotBefore.Sub(now)) + } + + sctTimestamps := mgr.parseSCTTimestamps(cert) + if len(sctTimestamps) == 0 { + return + } + + for i, sctTime := range sctTimestamps { + if sctTime.After(now) { + mgr.logger.Warnf("certificate for %q SCT[%d] timestamp is in the future: %v (by %v)", + domain, i, sctTime.UTC(), sctTime.Sub(now)) + } else { + mgr.logger.Debugf("certificate for %q SCT[%d] timestamp: %v (%v in the past)", + domain, i, sctTime.UTC(), now.Sub(sctTime)) + } + } +} + +// parseSCTTimestamps extracts SCT timestamps from a certificate. +func (mgr *Manager) parseSCTTimestamps(cert *x509.Certificate) []time.Time { + var timestamps []time.Time + + for _, ext := range cert.Extensions { + if !ext.Id.Equal(oidSCTList) { + continue + } + + // The extension value is an OCTET STRING containing the SCT list + var sctListBytes []byte + if _, err := asn1.Unmarshal(ext.Value, &sctListBytes); err != nil { + mgr.logger.Debugf("failed to unmarshal SCT list outer wrapper: %v", err) + continue + } + + // SCT list format: 2-byte length prefix, then concatenated SCTs + if len(sctListBytes) < 2 { + continue + } + + listLen := int(binary.BigEndian.Uint16(sctListBytes[:2])) + data := sctListBytes[2:] + if len(data) < listLen { + continue + } + + // Parse individual SCTs + offset := 0 + for offset < listLen { + if offset+2 > len(data) { + break + } + sctLen := int(binary.BigEndian.Uint16(data[offset : offset+2])) + offset += 2 + + if offset+sctLen > len(data) { + break + } + sctData := data[offset : offset+sctLen] + offset += sctLen + + // SCT format: version (1) + log_id (32) + timestamp (8) + ... + if len(sctData) < 41 { + continue + } + + // Timestamp is at offset 33 (after version + log_id), 8 bytes, milliseconds since epoch + tsMillis := binary.BigEndian.Uint64(sctData[33:41]) + ts := time.UnixMilli(int64(tsMillis)) + timestamps = append(timestamps, ts) + } + } + + return timestamps +} + +// dummyConn implements net.Conn to provide context for certificate fetching. +type dummyConn struct { + ctx context.Context +} + +func (c *dummyConn) Read(b []byte) (n int, err error) { return 0, nil } +func (c *dummyConn) Write(b []byte) (n int, err error) { return len(b), nil } +func (c *dummyConn) Close() error { return nil } +func (c *dummyConn) LocalAddr() net.Addr { return nil } +func (c *dummyConn) RemoteAddr() net.Addr { return nil } +func (c *dummyConn) SetDeadline(t time.Time) error { return nil } +func (c *dummyConn) SetReadDeadline(t time.Time) error { return nil } +func (c *dummyConn) SetWriteDeadline(t time.Time) error { return nil } + +// RemoveDomain removes a domain from tracking. +func (mgr *Manager) RemoveDomain(d domain.Domain) { + mgr.mu.Lock() + defer mgr.mu.Unlock() + delete(mgr.domains, d) +} + +// PendingCerts returns the number of certificates currently being prefetched. +func (mgr *Manager) PendingCerts() int { + mgr.mu.RLock() + defer mgr.mu.RUnlock() + var n int + for _, info := range mgr.domains { + if info.state == domainPending { + n++ + } + } + return n +} + +// TotalDomains returns the total number of registered domains. +func (mgr *Manager) TotalDomains() int { + mgr.mu.RLock() + defer mgr.mu.RUnlock() + return len(mgr.domains) +} + +// PendingDomains returns the domain names currently being prefetched. +func (mgr *Manager) PendingDomains() []string { + return mgr.domainsByState(domainPending) +} + +// ReadyDomains returns domain names that have successfully obtained certificates. +func (mgr *Manager) ReadyDomains() []string { + return mgr.domainsByState(domainReady) +} + +// FailedDomains returns domain names that failed certificate prefetch, mapped to their error. +func (mgr *Manager) FailedDomains() map[string]string { + mgr.mu.RLock() + defer mgr.mu.RUnlock() + result := make(map[string]string) + for d, info := range mgr.domains { + if info.state == domainFailed { + result[d.PunycodeString()] = info.err + } + } + return result +} + +func (mgr *Manager) domainsByState(state domainState) []string { + mgr.mu.RLock() + defer mgr.mu.RUnlock() + var domains []string + for d, info := range mgr.domains { + if info.state == state { + domains = append(domains, d.PunycodeString()) + } + } + slices.Sort(domains) + return domains +} diff --git a/proxy/internal/acme/manager_test.go b/proxy/internal/acme/manager_test.go new file mode 100644 index 000000000..ceb9ca13a --- /dev/null +++ b/proxy/internal/acme/manager_test.go @@ -0,0 +1,306 @@ +package acme + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/types" +) + +func TestHostPolicy(t *testing.T) { + mgr, err := NewManager(ManagerConfig{CertDir: t.TempDir(), ACMEURL: "https://acme.example.com/directory"}, nil, nil, nil) + require.NoError(t, err) + mgr.AddDomain("example.com", types.AccountID("acc1"), types.ServiceID("rp1")) + + // Wait for the background prefetch goroutine to finish so the temp dir + // can be cleaned up without a race. + t.Cleanup(func() { + assert.Eventually(t, func() bool { + return mgr.PendingCerts() == 0 + }, 30*time.Second, 50*time.Millisecond) + }) + + tests := []struct { + name string + host string + wantErr bool + }{ + { + name: "exact domain match", + host: "example.com", + }, + { + name: "domain with port", + host: "example.com:443", + }, + { + name: "unknown domain", + host: "unknown.com", + wantErr: true, + }, + { + name: "unknown domain with port", + host: "unknown.com:443", + wantErr: true, + }, + { + name: "empty host", + host: "", + wantErr: true, + }, + { + name: "port only", + host: ":443", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := mgr.hostPolicy(context.Background(), tc.host) + if tc.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown domain") + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestDomainStates(t *testing.T) { + mgr, err := NewManager(ManagerConfig{CertDir: t.TempDir(), ACMEURL: "https://acme.example.com/directory"}, nil, nil, nil) + require.NoError(t, err) + + assert.Equal(t, 0, mgr.PendingCerts(), "initially zero") + assert.Equal(t, 0, mgr.TotalDomains(), "initially zero domains") + assert.Empty(t, mgr.PendingDomains()) + assert.Empty(t, mgr.ReadyDomains()) + assert.Empty(t, mgr.FailedDomains()) + + // AddDomain starts as pending, then the prefetch goroutine will fail + // (no real ACME server) and transition to failed. + mgr.AddDomain("a.example.com", types.AccountID("acc1"), types.ServiceID("rp1")) + mgr.AddDomain("b.example.com", types.AccountID("acc1"), types.ServiceID("rp1")) + + assert.Equal(t, 2, mgr.TotalDomains(), "two domains registered") + + // Pending domains should eventually drain after prefetch goroutines finish. + assert.Eventually(t, func() bool { + return mgr.PendingCerts() == 0 + }, 30*time.Second, 100*time.Millisecond, "pending certs should return to zero after prefetch completes") + + assert.Empty(t, mgr.PendingDomains()) + assert.Equal(t, 2, mgr.TotalDomains(), "total domains unchanged") + + // With a fake ACME URL, both should have failed. + failed := mgr.FailedDomains() + assert.Len(t, failed, 2, "both domains should have failed") + assert.Contains(t, failed, "a.example.com") + assert.Contains(t, failed, "b.example.com") + assert.Empty(t, mgr.ReadyDomains()) +} + +func TestParseWildcard(t *testing.T) { + tests := []struct { + pattern string + wantSuffix string + wantOK bool + }{ + {"*.example.com", ".example.com", true}, + {"*.foo.example.com", ".foo.example.com", true}, + {"*.COM", ".com", true}, // single-label TLD + {"example.com", "", false}, // no wildcard prefix + {"*example.com", "", false}, // missing dot + {"**.example.com", "", false}, // double star + {"", "", false}, + } + + for _, tc := range tests { + t.Run(tc.pattern, func(t *testing.T) { + suffix, ok := parseWildcard(tc.pattern) + assert.Equal(t, tc.wantOK, ok) + if ok { + assert.Equal(t, tc.wantSuffix, suffix) + } + }) + } +} + +func TestMatchesWildcard(t *testing.T) { + wcDir := t.TempDir() + generateSelfSignedCert(t, wcDir, "example", "*.example.com") + + acmeDir := t.TempDir() + mgr, err := NewManager(ManagerConfig{CertDir: acmeDir, ACMEURL: "https://acme.example.com/directory", WildcardDir: wcDir}, nil, nil, nil) + require.NoError(t, err) + + tests := []struct { + host string + match bool + }{ + {"foo.example.com", true}, + {"bar.example.com", true}, + {"FOO.Example.COM", true}, // case insensitive + {"example.com", false}, // bare parent + {"sub.foo.example.com", false}, // multi-level + {"notexample.com", false}, + {"", false}, + } + + for _, tc := range tests { + t.Run(tc.host, func(t *testing.T) { + assert.Equal(t, tc.match, mgr.findWildcardEntry(tc.host) != nil) + }) + } +} + +// generateSelfSignedCert creates a temporary self-signed certificate and key +// for testing purposes. The baseName controls the output filenames: +// .crt and .key. +func generateSelfSignedCert(t *testing.T, dir, baseName string, dnsNames ...string) { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: dnsNames[0]}, + DNSNames: dnsNames, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + + certFile, err := os.Create(filepath.Join(dir, baseName+".crt")) + require.NoError(t, err) + require.NoError(t, pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: certDER})) + require.NoError(t, certFile.Close()) + + keyDER, err := x509.MarshalECPrivateKey(key) + require.NoError(t, err) + keyFile, err := os.Create(filepath.Join(dir, baseName+".key")) + require.NoError(t, err) + require.NoError(t, pem.Encode(keyFile, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})) + require.NoError(t, keyFile.Close()) +} + +func TestWildcardAddDomainSkipsACME(t *testing.T) { + wcDir := t.TempDir() + generateSelfSignedCert(t, wcDir, "example", "*.example.com") + + acmeDir := t.TempDir() + mgr, err := NewManager(ManagerConfig{CertDir: acmeDir, ACMEURL: "https://acme.example.com/directory", WildcardDir: wcDir}, nil, nil, nil) + require.NoError(t, err) + + // Add a wildcard-matching domain — should be immediately ready. + mgr.AddDomain("foo.example.com", types.AccountID("acc1"), types.ServiceID("svc1")) + assert.Equal(t, 0, mgr.PendingCerts(), "wildcard domain should not be pending") + assert.Equal(t, []string{"foo.example.com"}, mgr.ReadyDomains()) + + // Add a non-wildcard domain — should go through ACME (pending then failed). + mgr.AddDomain("other.net", types.AccountID("acc2"), types.ServiceID("svc2")) + assert.Equal(t, 2, mgr.TotalDomains()) + + // Wait for the ACME prefetch to fail. + assert.Eventually(t, func() bool { + return mgr.PendingCerts() == 0 + }, 30*time.Second, 100*time.Millisecond) + + assert.Equal(t, []string{"foo.example.com"}, mgr.ReadyDomains()) + assert.Contains(t, mgr.FailedDomains(), "other.net") +} + +func TestWildcardGetCertificate(t *testing.T) { + wcDir := t.TempDir() + generateSelfSignedCert(t, wcDir, "example", "*.example.com") + + acmeDir := t.TempDir() + mgr, err := NewManager(ManagerConfig{CertDir: acmeDir, ACMEURL: "https://acme.example.com/directory", WildcardDir: wcDir}, nil, nil, nil) + require.NoError(t, err) + + mgr.AddDomain("foo.example.com", types.AccountID("acc1"), types.ServiceID("svc1")) + + // GetCertificate for a wildcard-matching domain should return the static cert. + cert, err := mgr.GetCertificate(&tls.ClientHelloInfo{ServerName: "foo.example.com"}) + require.NoError(t, err) + require.NotNil(t, cert) + assert.Contains(t, cert.Leaf.DNSNames, "*.example.com") +} + +func TestMultipleWildcards(t *testing.T) { + wcDir := t.TempDir() + generateSelfSignedCert(t, wcDir, "example", "*.example.com") + generateSelfSignedCert(t, wcDir, "other", "*.other.org") + + acmeDir := t.TempDir() + mgr, err := NewManager(ManagerConfig{CertDir: acmeDir, ACMEURL: "https://acme.example.com/directory", WildcardDir: wcDir}, nil, nil, nil) + require.NoError(t, err) + + assert.ElementsMatch(t, []string{"*.example.com", "*.other.org"}, mgr.WildcardPatterns()) + + // Both wildcards should resolve. + mgr.AddDomain("foo.example.com", types.AccountID("acc1"), types.ServiceID("svc1")) + mgr.AddDomain("bar.other.org", types.AccountID("acc2"), types.ServiceID("svc2")) + + assert.Equal(t, 0, mgr.PendingCerts()) + assert.ElementsMatch(t, []string{"foo.example.com", "bar.other.org"}, mgr.ReadyDomains()) + + // GetCertificate routes to the correct cert. + cert1, err := mgr.GetCertificate(&tls.ClientHelloInfo{ServerName: "foo.example.com"}) + require.NoError(t, err) + assert.Contains(t, cert1.Leaf.DNSNames, "*.example.com") + + cert2, err := mgr.GetCertificate(&tls.ClientHelloInfo{ServerName: "bar.other.org"}) + require.NoError(t, err) + assert.Contains(t, cert2.Leaf.DNSNames, "*.other.org") + + // Non-matching domain falls through to ACME. + mgr.AddDomain("custom.net", types.AccountID("acc3"), types.ServiceID("svc3")) + assert.Eventually(t, func() bool { + return mgr.PendingCerts() == 0 + }, 30*time.Second, 100*time.Millisecond) + assert.Contains(t, mgr.FailedDomains(), "custom.net") +} + +func TestWildcardDirEmpty(t *testing.T) { + wcDir := t.TempDir() + // Empty directory — no .crt files. + _, err := NewManager(ManagerConfig{CertDir: t.TempDir(), ACMEURL: "https://acme.example.com/directory", WildcardDir: wcDir}, nil, nil, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "no .crt files found") +} + +func TestWildcardDirNonWildcardCert(t *testing.T) { + wcDir := t.TempDir() + // Certificate without a wildcard SAN. + generateSelfSignedCert(t, wcDir, "plain", "plain.example.com") + + _, err := NewManager(ManagerConfig{CertDir: t.TempDir(), ACMEURL: "https://acme.example.com/directory", WildcardDir: wcDir}, nil, nil, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "no wildcard SANs") +} + +func TestNoWildcardDir(t *testing.T) { + // Empty string means no wildcard dir — pure ACME mode. + mgr, err := NewManager(ManagerConfig{CertDir: t.TempDir(), ACMEURL: "https://acme.example.com/directory"}, nil, nil, nil) + require.NoError(t, err) + assert.Empty(t, mgr.WildcardPatterns()) +} diff --git a/proxy/internal/auth/auth.gohtml b/proxy/internal/auth/auth.gohtml new file mode 100644 index 000000000..9cd36b796 --- /dev/null +++ b/proxy/internal/auth/auth.gohtml @@ -0,0 +1,18 @@ + +{{ range $method, $value := .Methods }} +{{ if eq $method "pin" }} +
+ + + +
+{{ else if eq $method "password" }} +
+ + + +
+{{ else if eq $method "oidc" }} +Click here to log in with SSO +{{ end }} +{{ end }} diff --git a/proxy/internal/auth/header.go b/proxy/internal/auth/header.go new file mode 100644 index 000000000..194800a49 --- /dev/null +++ b/proxy/internal/auth/header.go @@ -0,0 +1,69 @@ +package auth + +import ( + "errors" + "fmt" + "net/http" + + "github.com/netbirdio/netbird/proxy/auth" + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// ErrHeaderAuthFailed indicates that the header was present but the +// credential did not validate. Callers should return 401 instead of +// falling through to other auth schemes. +var ErrHeaderAuthFailed = errors.New("header authentication failed") + +// Header implements header-based authentication. The proxy checks for the +// configured header in each request and validates its value via gRPC. +type Header struct { + id types.ServiceID + accountId types.AccountID + headerName string + client authenticator +} + +// NewHeader creates a Header authentication scheme for the given header name. +func NewHeader(client authenticator, id types.ServiceID, accountId types.AccountID, headerName string) Header { + return Header{ + id: id, + accountId: accountId, + headerName: headerName, + client: client, + } +} + +// Type returns auth.MethodHeader. +func (Header) Type() auth.Method { + return auth.MethodHeader +} + +// Authenticate checks for the configured header in the request. If absent, +// returns empty (unauthenticated). If present, validates via gRPC. +func (h Header) Authenticate(r *http.Request) (string, string, error) { + value := r.Header.Get(h.headerName) + if value == "" { + return "", "", nil + } + + res, err := h.client.Authenticate(r.Context(), &proto.AuthenticateRequest{ + Id: string(h.id), + AccountId: string(h.accountId), + Request: &proto.AuthenticateRequest_HeaderAuth{ + HeaderAuth: &proto.HeaderAuthRequest{ + HeaderValue: value, + HeaderName: h.headerName, + }, + }, + }) + if err != nil { + return "", "", fmt.Errorf("authenticate header: %w", err) + } + + if res.GetSuccess() { + return res.GetSessionToken(), "", nil + } + + return "", "", ErrHeaderAuthFailed +} diff --git a/proxy/internal/auth/middleware.go b/proxy/internal/auth/middleware.go new file mode 100644 index 000000000..670cafb68 --- /dev/null +++ b/proxy/internal/auth/middleware.go @@ -0,0 +1,525 @@ +package auth + +import ( + "context" + "crypto/ed25519" + "encoding/base64" + "errors" + "fmt" + "html" + "net" + "net/http" + "net/netip" + "net/url" + "sync" + "time" + + log "github.com/sirupsen/logrus" + "google.golang.org/grpc" + + "github.com/netbirdio/netbird/proxy/auth" + "github.com/netbirdio/netbird/proxy/internal/proxy" + "github.com/netbirdio/netbird/proxy/internal/restrict" + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/proxy/web" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// errValidationUnavailable indicates that session validation failed due to +// an infrastructure error (e.g. gRPC unavailable), not an invalid token. +var errValidationUnavailable = errors.New("session validation unavailable") + +type authenticator interface { + Authenticate(ctx context.Context, in *proto.AuthenticateRequest, opts ...grpc.CallOption) (*proto.AuthenticateResponse, error) +} + +// SessionValidator validates session tokens and checks user access permissions. +type SessionValidator interface { + ValidateSession(ctx context.Context, in *proto.ValidateSessionRequest, opts ...grpc.CallOption) (*proto.ValidateSessionResponse, error) +} + +// Scheme defines an authentication mechanism for a domain. +type Scheme interface { + Type() auth.Method + // Authenticate checks the request and determines whether it represents + // an authenticated user. An empty token indicates an unauthenticated + // request; optionally, promptData may be returned for the login UI. + // An error indicates an infrastructure failure (e.g. gRPC unavailable). + Authenticate(*http.Request) (token string, promptData string, err error) +} + +// DomainConfig holds the authentication and restriction settings for a protected domain. +type DomainConfig struct { + Schemes []Scheme + SessionPublicKey ed25519.PublicKey + SessionExpiration time.Duration + AccountID types.AccountID + ServiceID types.ServiceID + IPRestrictions *restrict.Filter +} + +type validationResult struct { + UserID string + Valid bool + DeniedReason string +} + +// Middleware applies per-domain authentication and IP restriction checks. +type Middleware struct { + domainsMux sync.RWMutex + domains map[string]DomainConfig + logger *log.Logger + sessionValidator SessionValidator + geo restrict.GeoResolver +} + +// NewMiddleware creates a new authentication middleware. The sessionValidator is +// optional; if nil, OIDC session tokens are validated locally without group access checks. +func NewMiddleware(logger *log.Logger, sessionValidator SessionValidator, geo restrict.GeoResolver) *Middleware { + if logger == nil { + logger = log.StandardLogger() + } + return &Middleware{ + domains: make(map[string]DomainConfig), + logger: logger, + sessionValidator: sessionValidator, + geo: geo, + } +} + +// Protect wraps next with per-domain authentication and IP restriction checks. +// Requests whose Host is not registered pass through unchanged. +func (mw *Middleware) Protect(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host, _, err := net.SplitHostPort(r.Host) + if err != nil { + host = r.Host + } + + config, exists := mw.getDomainConfig(host) + mw.logger.Debugf("checking authentication for host: %s, exists: %t", host, exists) + + if !exists { + next.ServeHTTP(w, r) + return + } + + // Set account and service IDs in captured data for access logging. + setCapturedIDs(r, config) + + if !mw.checkIPRestrictions(w, r, config) { + return + } + + // Domains with no authentication schemes pass through after IP checks. + if len(config.Schemes) == 0 { + next.ServeHTTP(w, r) + return + } + + if mw.handleOAuthCallbackError(w, r) { + return + } + + if mw.forwardWithSessionCookie(w, r, host, config, next) { + return + } + + if mw.forwardWithHeaderAuth(w, r, host, config, next) { + return + } + + mw.authenticateWithSchemes(w, r, host, config) + }) +} + +func (mw *Middleware) getDomainConfig(host string) (DomainConfig, bool) { + mw.domainsMux.RLock() + defer mw.domainsMux.RUnlock() + config, exists := mw.domains[host] + return config, exists +} + +func setCapturedIDs(r *http.Request, config DomainConfig) { + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { + cd.SetAccountID(config.AccountID) + cd.SetServiceID(config.ServiceID) + } +} + +// checkIPRestrictions validates the client IP against the domain's IP restrictions. +// Uses the resolved client IP from CapturedData (which accounts for trusted proxies) +// rather than r.RemoteAddr directly. +func (mw *Middleware) checkIPRestrictions(w http.ResponseWriter, r *http.Request, config DomainConfig) bool { + if config.IPRestrictions == nil { + return true + } + + clientIP := mw.resolveClientIP(r) + if !clientIP.IsValid() { + mw.logger.Debugf("IP restriction: cannot resolve client address for %q, denying", r.RemoteAddr) + http.Error(w, "Forbidden", http.StatusForbidden) + return false + } + + verdict := config.IPRestrictions.Check(clientIP, mw.geo) + if verdict == restrict.Allow { + return true + } + + reason := verdict.String() + mw.blockIPRestriction(r, reason) + http.Error(w, "Forbidden", http.StatusForbidden) + return false +} + +// resolveClientIP extracts the real client IP from CapturedData, falling back to r.RemoteAddr. +func (mw *Middleware) resolveClientIP(r *http.Request) netip.Addr { + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { + if ip := cd.GetClientIP(); ip.IsValid() { + return ip + } + } + + clientIPStr, _, _ := net.SplitHostPort(r.RemoteAddr) + if clientIPStr == "" { + clientIPStr = r.RemoteAddr + } + addr, err := netip.ParseAddr(clientIPStr) + if err != nil { + return netip.Addr{} + } + return addr.Unmap() +} + +// blockIPRestriction sets captured data fields for an IP-restriction block event. +func (mw *Middleware) blockIPRestriction(r *http.Request, reason string) { + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { + cd.SetOrigin(proxy.OriginAuth) + cd.SetAuthMethod(reason) + } + mw.logger.Debugf("IP restriction: %s for %s", reason, r.RemoteAddr) +} + +// handleOAuthCallbackError checks for error query parameters from an OAuth +// callback and renders the access denied page if present. +func (mw *Middleware) handleOAuthCallbackError(w http.ResponseWriter, r *http.Request) bool { + errCode := r.URL.Query().Get("error") + if errCode == "" { + return false + } + + var requestID string + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { + cd.SetOrigin(proxy.OriginAuth) + cd.SetAuthMethod(auth.MethodOIDC.String()) + requestID = cd.GetRequestID() + } + errDesc := r.URL.Query().Get("error_description") + if errDesc == "" { + errDesc = "An error occurred during authentication" + } else { + errDesc = html.EscapeString(errDesc) + } + web.ServeAccessDeniedPage(w, r, http.StatusForbidden, "Access Denied", errDesc, requestID) + return true +} + +// forwardWithSessionCookie checks for a valid session cookie and, if found, +// sets the user identity on the request context and forwards to the next handler. +func (mw *Middleware) forwardWithSessionCookie(w http.ResponseWriter, r *http.Request, host string, config DomainConfig, next http.Handler) bool { + cookie, err := r.Cookie(auth.SessionCookieName) + if err != nil { + return false + } + userID, method, err := auth.ValidateSessionJWT(cookie.Value, host, config.SessionPublicKey) + if err != nil { + return false + } + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { + cd.SetUserID(userID) + cd.SetAuthMethod(method) + } + next.ServeHTTP(w, r) + return true +} + +// forwardWithHeaderAuth checks for a Header auth scheme. If the header validates, +// the request is forwarded directly (no redirect), which is important for API clients. +func (mw *Middleware) forwardWithHeaderAuth(w http.ResponseWriter, r *http.Request, host string, config DomainConfig, next http.Handler) bool { + for _, scheme := range config.Schemes { + hdr, ok := scheme.(Header) + if !ok { + continue + } + + handled := mw.tryHeaderScheme(w, r, host, config, hdr, next) + if handled { + return true + } + } + return false +} + +func (mw *Middleware) tryHeaderScheme(w http.ResponseWriter, r *http.Request, host string, config DomainConfig, hdr Header, next http.Handler) bool { + token, _, err := hdr.Authenticate(r) + if err != nil { + return mw.handleHeaderAuthError(w, r, err) + } + if token == "" { + return false + } + + result, err := mw.validateSessionToken(r.Context(), host, token, config.SessionPublicKey, auth.MethodHeader) + if err != nil { + setHeaderCapturedData(r.Context(), "") + status := http.StatusBadRequest + msg := "invalid session token" + if errors.Is(err, errValidationUnavailable) { + status = http.StatusBadGateway + msg = "authentication service unavailable" + } + http.Error(w, msg, status) + return true + } + + if !result.Valid { + setHeaderCapturedData(r.Context(), result.UserID) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return true + } + + setSessionCookie(w, token, config.SessionExpiration) + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { + cd.SetUserID(result.UserID) + cd.SetAuthMethod(auth.MethodHeader.String()) + } + + next.ServeHTTP(w, r) + return true +} + +func (mw *Middleware) handleHeaderAuthError(w http.ResponseWriter, r *http.Request, err error) bool { + if errors.Is(err, ErrHeaderAuthFailed) { + setHeaderCapturedData(r.Context(), "") + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return true + } + mw.logger.WithField("scheme", "header").Warnf("header auth infrastructure error: %v", err) + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { + cd.SetOrigin(proxy.OriginAuth) + } + http.Error(w, "authentication service unavailable", http.StatusBadGateway) + return true +} + +func setHeaderCapturedData(ctx context.Context, userID string) { + cd := proxy.CapturedDataFromContext(ctx) + if cd == nil { + return + } + cd.SetOrigin(proxy.OriginAuth) + cd.SetAuthMethod(auth.MethodHeader.String()) + cd.SetUserID(userID) +} + +// authenticateWithSchemes tries each configured auth scheme in order. +// On success it sets a session cookie and redirects; on failure it renders the login page. +func (mw *Middleware) authenticateWithSchemes(w http.ResponseWriter, r *http.Request, host string, config DomainConfig) { + methods := make(map[string]string) + var attemptedMethod string + + for _, scheme := range config.Schemes { + token, promptData, err := scheme.Authenticate(r) + if err != nil { + mw.logger.WithField("scheme", scheme.Type().String()).Warnf("authentication infrastructure error: %v", err) + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { + cd.SetOrigin(proxy.OriginAuth) + } + http.Error(w, "authentication service unavailable", http.StatusBadGateway) + return + } + + // Track if credentials were submitted but auth failed + if token == "" && wasCredentialSubmitted(r, scheme.Type()) { + attemptedMethod = scheme.Type().String() + } + + if token != "" { + mw.handleAuthenticatedToken(w, r, host, token, config, scheme) + return + } + methods[scheme.Type().String()] = promptData + } + + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { + cd.SetOrigin(proxy.OriginAuth) + if attemptedMethod != "" { + cd.SetAuthMethod(attemptedMethod) + } + } + web.ServeHTTP(w, r, map[string]any{"methods": methods}, http.StatusUnauthorized) +} + +// handleAuthenticatedToken validates the token, handles denied access, and on +// success sets a session cookie and redirects to the original URL. +func (mw *Middleware) handleAuthenticatedToken(w http.ResponseWriter, r *http.Request, host, token string, config DomainConfig, scheme Scheme) { + result, err := mw.validateSessionToken(r.Context(), host, token, config.SessionPublicKey, scheme.Type()) + if err != nil { + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { + cd.SetOrigin(proxy.OriginAuth) + cd.SetAuthMethod(scheme.Type().String()) + } + status := http.StatusBadRequest + msg := "invalid session token" + if errors.Is(err, errValidationUnavailable) { + status = http.StatusBadGateway + msg = "authentication service unavailable" + } + http.Error(w, msg, status) + return + } + + if !result.Valid { + var requestID string + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { + cd.SetOrigin(proxy.OriginAuth) + cd.SetUserID(result.UserID) + cd.SetAuthMethod(scheme.Type().String()) + requestID = cd.GetRequestID() + } + web.ServeAccessDeniedPage(w, r, http.StatusForbidden, "Access Denied", "You are not authorized to access this service", requestID) + return + } + + setSessionCookie(w, token, config.SessionExpiration) + + // Redirect instead of forwarding the auth POST to the backend. + // The browser will follow with a GET carrying the new session cookie. + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { + cd.SetOrigin(proxy.OriginAuth) + cd.SetUserID(result.UserID) + cd.SetAuthMethod(scheme.Type().String()) + } + redirectURL := stripSessionTokenParam(r.URL) + http.Redirect(w, r, redirectURL, http.StatusSeeOther) +} + +// setSessionCookie writes a session cookie with secure defaults. +func setSessionCookie(w http.ResponseWriter, token string, expiration time.Duration) { + if expiration == 0 { + expiration = auth.DefaultSessionExpiry + } + http.SetCookie(w, &http.Cookie{ + Name: auth.SessionCookieName, + Value: token, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + MaxAge: int(expiration.Seconds()), + }) +} + +// wasCredentialSubmitted checks if credentials were submitted for the given auth method. +func wasCredentialSubmitted(r *http.Request, method auth.Method) bool { + switch method { + case auth.MethodPIN: + return r.FormValue("pin") != "" + case auth.MethodPassword: + return r.FormValue("password") != "" + case auth.MethodOIDC: + return r.URL.Query().Get("session_token") != "" + } + return false +} + +// AddDomain registers authentication schemes for the given domain. +// If schemes are provided, a valid session public key is required to sign/verify +// session JWTs. Returns an error if the key is missing or invalid. +// Callers must not serve the domain if this returns an error, to avoid +// exposing an unauthenticated service. +func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 string, expiration time.Duration, accountID types.AccountID, serviceID types.ServiceID, ipRestrictions *restrict.Filter) error { + if len(schemes) == 0 { + mw.domainsMux.Lock() + defer mw.domainsMux.Unlock() + mw.domains[domain] = DomainConfig{ + AccountID: accountID, + ServiceID: serviceID, + IPRestrictions: ipRestrictions, + } + return nil + } + + pubKeyBytes, err := base64.StdEncoding.DecodeString(publicKeyB64) + if err != nil { + return fmt.Errorf("decode session public key for domain %s: %w", domain, err) + } + if len(pubKeyBytes) != ed25519.PublicKeySize { + return fmt.Errorf("invalid session public key size for domain %s: got %d, want %d", domain, len(pubKeyBytes), ed25519.PublicKeySize) + } + + mw.domainsMux.Lock() + defer mw.domainsMux.Unlock() + mw.domains[domain] = DomainConfig{ + Schemes: schemes, + SessionPublicKey: pubKeyBytes, + SessionExpiration: expiration, + AccountID: accountID, + ServiceID: serviceID, + IPRestrictions: ipRestrictions, + } + return nil +} + +// RemoveDomain unregisters authentication for the given domain. +func (mw *Middleware) RemoveDomain(domain string) { + mw.domainsMux.Lock() + defer mw.domainsMux.Unlock() + delete(mw.domains, domain) +} + +// validateSessionToken validates a session token. OIDC tokens with a configured +// validator go through gRPC for group access checks; other methods validate locally. +func (mw *Middleware) validateSessionToken(ctx context.Context, host, token string, publicKey ed25519.PublicKey, method auth.Method) (*validationResult, error) { + if method == auth.MethodOIDC && mw.sessionValidator != nil { + resp, err := mw.sessionValidator.ValidateSession(ctx, &proto.ValidateSessionRequest{ + Domain: host, + SessionToken: token, + }) + if err != nil { + return nil, fmt.Errorf("%w: %w", errValidationUnavailable, err) + } + if !resp.Valid { + mw.logger.WithFields(log.Fields{ + "domain": host, + "denied_reason": resp.DeniedReason, + "user_id": resp.UserId, + }).Debug("Session validation denied") + return &validationResult{ + UserID: resp.UserId, + Valid: false, + DeniedReason: resp.DeniedReason, + }, nil + } + return &validationResult{UserID: resp.UserId, Valid: true}, nil + } + + userID, _, err := auth.ValidateSessionJWT(token, host, publicKey) + if err != nil { + return nil, err + } + return &validationResult{UserID: userID, Valid: true}, nil +} + +// stripSessionTokenParam returns the request URI with the session_token query +// parameter removed so it doesn't linger in the browser's address bar or history. +func stripSessionTokenParam(u *url.URL) string { + q := u.Query() + if !q.Has("session_token") { + return u.RequestURI() + } + q.Del("session_token") + clean := *u + clean.RawQuery = q.Encode() + return clean.RequestURI() +} diff --git a/proxy/internal/auth/middleware_test.go b/proxy/internal/auth/middleware_test.go new file mode 100644 index 000000000..a4924d380 --- /dev/null +++ b/proxy/internal/auth/middleware_test.go @@ -0,0 +1,934 @@ +package auth + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "errors" + "net/http" + "net/http/httptest" + "net/netip" + "net/url" + "strings" + "testing" + "time" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey" + "github.com/netbirdio/netbird/proxy/auth" + "github.com/netbirdio/netbird/proxy/internal/proxy" + "github.com/netbirdio/netbird/proxy/internal/restrict" + "github.com/netbirdio/netbird/shared/management/proto" +) + +func generateTestKeyPair(t *testing.T) *sessionkey.KeyPair { + t.Helper() + kp, err := sessionkey.GenerateKeyPair() + require.NoError(t, err) + return kp +} + +// stubScheme is a minimal Scheme implementation for testing. +type stubScheme struct { + method auth.Method + token string + promptID string + authFn func(*http.Request) (string, string, error) +} + +func (s *stubScheme) Type() auth.Method { return s.method } + +func (s *stubScheme) Authenticate(r *http.Request) (string, string, error) { + if s.authFn != nil { + return s.authFn(r) + } + return s.token, s.promptID, nil +} + +func newPassthroughHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("backend")) + }) +} + +func TestAddDomain_ValidKey(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + err := mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil) + require.NoError(t, err) + + mw.domainsMux.RLock() + config, exists := mw.domains["example.com"] + mw.domainsMux.RUnlock() + + assert.True(t, exists, "domain should be registered") + assert.Len(t, config.Schemes, 1) + assert.Equal(t, ed25519.PublicKeySize, len(config.SessionPublicKey)) + assert.Equal(t, time.Hour, config.SessionExpiration) +} + +func TestAddDomain_EmptyKey(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + + scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + err := mw.AddDomain("example.com", []Scheme{scheme}, "", time.Hour, "", "", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid session public key size") + + mw.domainsMux.RLock() + _, exists := mw.domains["example.com"] + mw.domainsMux.RUnlock() + assert.False(t, exists, "domain must not be registered with an empty session key") +} + +func TestAddDomain_InvalidBase64(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + + scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + err := mw.AddDomain("example.com", []Scheme{scheme}, "not-valid-base64!!!", time.Hour, "", "", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "decode session public key") + + mw.domainsMux.RLock() + _, exists := mw.domains["example.com"] + mw.domainsMux.RUnlock() + assert.False(t, exists, "domain must not be registered with invalid base64 key") +} + +func TestAddDomain_WrongKeySize(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + + shortKey := base64.StdEncoding.EncodeToString([]byte("tooshort")) + scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + err := mw.AddDomain("example.com", []Scheme{scheme}, shortKey, time.Hour, "", "", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid session public key size") + + mw.domainsMux.RLock() + _, exists := mw.domains["example.com"] + mw.domainsMux.RUnlock() + assert.False(t, exists, "domain must not be registered with a wrong-size key") +} + +func TestAddDomain_NoSchemes_NoKeyRequired(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + + err := mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil) + require.NoError(t, err, "domains with no auth schemes should not require a key") + + mw.domainsMux.RLock() + _, exists := mw.domains["example.com"] + mw.domainsMux.RUnlock() + assert.True(t, exists) +} + +func TestAddDomain_OverwritesPreviousConfig(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp1 := generateTestKeyPair(t) + kp2 := generateTestKeyPair(t) + + scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp2.PublicKey, 2*time.Hour, "", "", nil)) + + mw.domainsMux.RLock() + config := mw.domains["example.com"] + mw.domainsMux.RUnlock() + + pubKeyBytes, _ := base64.StdEncoding.DecodeString(kp2.PublicKey) + assert.Equal(t, ed25519.PublicKey(pubKeyBytes), config.SessionPublicKey, "should use the latest key") + assert.Equal(t, 2*time.Hour, config.SessionExpiration) +} + +func TestRemoveDomain(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil)) + + mw.RemoveDomain("example.com") + + mw.domainsMux.RLock() + _, exists := mw.domains["example.com"] + mw.domainsMux.RUnlock() + assert.False(t, exists) +} + +func TestProtect_UnknownDomainPassesThrough(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + handler := mw.Protect(newPassthroughHandler()) + + req := httptest.NewRequest(http.MethodGet, "http://unknown.com/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "backend", rec.Body.String()) +} + +func TestProtect_DomainWithNoSchemesPassesThrough(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + require.NoError(t, mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil)) + + handler := mw.Protect(newPassthroughHandler()) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "backend", rec.Body.String()) +} + +func TestProtect_UnauthenticatedRequestIsBlocked(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil)) + + var backendCalled bool + backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + backendCalled = true + w.WriteHeader(http.StatusOK) + }) + handler := mw.Protect(backend) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.False(t, backendCalled, "unauthenticated request should not reach backend") +} + +func TestProtect_HostWithPortIsMatched(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil)) + + var backendCalled bool + backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + backendCalled = true + w.WriteHeader(http.StatusOK) + }) + handler := mw.Protect(backend) + + req := httptest.NewRequest(http.MethodGet, "http://example.com:8443/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.False(t, backendCalled, "host with port should still match the protected domain") +} + +func TestProtect_ValidSessionCookiePassesThrough(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil)) + + token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "example.com", auth.MethodPIN, time.Hour) + require.NoError(t, err) + + capturedData := proxy.NewCapturedData("") + handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cd := proxy.CapturedDataFromContext(r.Context()) + require.NotNil(t, cd) + assert.Equal(t, "test-user", cd.GetUserID()) + assert.Equal(t, "pin", cd.GetAuthMethod()) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("authenticated")) + })) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req = req.WithContext(proxy.WithCapturedData(req.Context(), capturedData)) + req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: token}) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "authenticated", rec.Body.String()) +} + +func TestProtect_ExpiredSessionCookieIsRejected(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil)) + + // Sign a token that expired 1 second ago. + token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "example.com", auth.MethodPIN, -time.Second) + require.NoError(t, err) + + var backendCalled bool + backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + backendCalled = true + w.WriteHeader(http.StatusOK) + }) + handler := mw.Protect(backend) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: token}) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.False(t, backendCalled, "expired session should not reach the backend") +} + +func TestProtect_WrongDomainCookieIsRejected(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil)) + + // Token signed for a different domain audience. + token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "other.com", auth.MethodPIN, time.Hour) + require.NoError(t, err) + + var backendCalled bool + backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + backendCalled = true + w.WriteHeader(http.StatusOK) + }) + handler := mw.Protect(backend) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: token}) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.False(t, backendCalled, "cookie for wrong domain should be rejected") +} + +func TestProtect_WrongKeyCookieIsRejected(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp1 := generateTestKeyPair(t) + kp2 := generateTestKeyPair(t) + + scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil)) + + // Token signed with a different private key. + token, err := sessionkey.SignToken(kp2.PrivateKey, "test-user", "example.com", auth.MethodPIN, time.Hour) + require.NoError(t, err) + + var backendCalled bool + backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + backendCalled = true + w.WriteHeader(http.StatusOK) + }) + handler := mw.Protect(backend) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: token}) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.False(t, backendCalled, "cookie signed by wrong key should be rejected") +} + +func TestProtect_SchemeAuthRedirectsWithCookie(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + token, err := sessionkey.SignToken(kp.PrivateKey, "pin-user", "example.com", auth.MethodPIN, time.Hour) + require.NoError(t, err) + + scheme := &stubScheme{ + method: auth.MethodPIN, + authFn: func(r *http.Request) (string, string, error) { + if r.FormValue("pin") == "111111" { + return token, "", nil + } + return "", "pin", nil + }, + } + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil)) + + var backendCalled bool + backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + backendCalled = true + w.WriteHeader(http.StatusOK) + }) + handler := mw.Protect(backend) + + // Submit the PIN via form POST. + form := url.Values{"pin": {"111111"}} + req := httptest.NewRequest(http.MethodPost, "http://example.com/somepath", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.False(t, backendCalled, "backend should not be called during auth, only a redirect should be returned") + assert.Equal(t, http.StatusSeeOther, rec.Code) + assert.Equal(t, "/somepath", rec.Header().Get("Location"), "redirect should point to the original request URI") + + cookies := rec.Result().Cookies() + var sessionCookie *http.Cookie + for _, c := range cookies { + if c.Name == auth.SessionCookieName { + sessionCookie = c + break + } + } + require.NotNil(t, sessionCookie, "session cookie should be set after successful auth") + assert.True(t, sessionCookie.HttpOnly) + assert.True(t, sessionCookie.Secure) + assert.Equal(t, http.SameSiteLaxMode, sessionCookie.SameSite) +} + +func TestProtect_FailedAuthDoesNotSetCookie(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + scheme := &stubScheme{ + method: auth.MethodPIN, + authFn: func(_ *http.Request) (string, string, error) { + return "", "pin", nil + }, + } + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil)) + + handler := mw.Protect(newPassthroughHandler()) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + for _, c := range rec.Result().Cookies() { + assert.NotEqual(t, auth.SessionCookieName, c.Name, "no session cookie should be set on failed auth") + } +} + +func TestProtect_MultipleSchemes(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + token, err := sessionkey.SignToken(kp.PrivateKey, "password-user", "example.com", auth.MethodPassword, time.Hour) + require.NoError(t, err) + + // First scheme (PIN) always fails, second scheme (password) succeeds. + pinScheme := &stubScheme{ + method: auth.MethodPIN, + authFn: func(_ *http.Request) (string, string, error) { + return "", "pin", nil + }, + } + passwordScheme := &stubScheme{ + method: auth.MethodPassword, + authFn: func(r *http.Request) (string, string, error) { + if r.FormValue("password") == "secret" { + return token, "", nil + } + return "", "password", nil + }, + } + require.NoError(t, mw.AddDomain("example.com", []Scheme{pinScheme, passwordScheme}, kp.PublicKey, time.Hour, "", "", nil)) + + var backendCalled bool + backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + backendCalled = true + w.WriteHeader(http.StatusOK) + }) + handler := mw.Protect(backend) + + form := url.Values{"password": {"secret"}} + req := httptest.NewRequest(http.MethodPost, "http://example.com/", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.False(t, backendCalled, "backend should not be called during auth") + assert.Equal(t, http.StatusSeeOther, rec.Code) +} + +func TestProtect_InvalidTokenFromSchemeReturns400(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + // Return a garbage token that won't validate. + scheme := &stubScheme{ + method: auth.MethodPIN, + authFn: func(_ *http.Request) (string, string, error) { + return "invalid-jwt-token", "", nil + }, + } + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil)) + + handler := mw.Protect(newPassthroughHandler()) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestAddDomain_RandomBytes32NotEd25519(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + + // 32 random bytes that happen to be valid base64 and correct size + // but are actually a valid ed25519 public key length-wise. + // This should succeed because ed25519 public keys are just 32 bytes. + randomBytes := make([]byte, ed25519.PublicKeySize) + _, err := rand.Read(randomBytes) + require.NoError(t, err) + + key := base64.StdEncoding.EncodeToString(randomBytes) + scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + + err = mw.AddDomain("example.com", []Scheme{scheme}, key, time.Hour, "", "", nil) + require.NoError(t, err, "any 32-byte key should be accepted at registration time") +} + +func TestAddDomain_InvalidKeyDoesNotCorruptExistingConfig(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil)) + + // Attempt to overwrite with an invalid key. + err := mw.AddDomain("example.com", []Scheme{scheme}, "bad", time.Hour, "", "", nil) + require.Error(t, err) + + // The original valid config should still be intact. + mw.domainsMux.RLock() + config, exists := mw.domains["example.com"] + mw.domainsMux.RUnlock() + + assert.True(t, exists, "original config should still exist") + assert.Len(t, config.Schemes, 1) + assert.Equal(t, time.Hour, config.SessionExpiration) +} + +func TestProtect_FailedPinAuthCapturesAuthMethod(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + // Scheme that always fails authentication (returns empty token) + scheme := &stubScheme{ + method: auth.MethodPIN, + authFn: func(_ *http.Request) (string, string, error) { + return "", "pin", nil + }, + } + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil)) + + capturedData := proxy.NewCapturedData("") + handler := mw.Protect(newPassthroughHandler()) + + // Submit wrong PIN - should capture auth method + form := url.Values{"pin": {"wrong-pin"}} + req := httptest.NewRequest(http.MethodPost, "http://example.com/", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req = req.WithContext(proxy.WithCapturedData(req.Context(), capturedData)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Equal(t, "pin", capturedData.GetAuthMethod(), "Auth method should be captured for failed PIN auth") +} + +func TestProtect_FailedPasswordAuthCapturesAuthMethod(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + scheme := &stubScheme{ + method: auth.MethodPassword, + authFn: func(_ *http.Request) (string, string, error) { + return "", "password", nil + }, + } + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil)) + + capturedData := proxy.NewCapturedData("") + handler := mw.Protect(newPassthroughHandler()) + + // Submit wrong password - should capture auth method + form := url.Values{"password": {"wrong-password"}} + req := httptest.NewRequest(http.MethodPost, "http://example.com/", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req = req.WithContext(proxy.WithCapturedData(req.Context(), capturedData)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Equal(t, "password", capturedData.GetAuthMethod(), "Auth method should be captured for failed password auth") +} + +func TestProtect_NoCredentialsDoesNotCaptureAuthMethod(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + scheme := &stubScheme{ + method: auth.MethodPIN, + authFn: func(_ *http.Request) (string, string, error) { + return "", "pin", nil + }, + } + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil)) + + capturedData := proxy.NewCapturedData("") + handler := mw.Protect(newPassthroughHandler()) + + // No credentials submitted - should not capture auth method + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req = req.WithContext(proxy.WithCapturedData(req.Context(), capturedData)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Empty(t, capturedData.GetAuthMethod(), "Auth method should not be captured when no credentials submitted") +} + +func TestWasCredentialSubmitted(t *testing.T) { + tests := []struct { + name string + method auth.Method + formData url.Values + query url.Values + expected bool + }{ + { + name: "PIN submitted", + method: auth.MethodPIN, + formData: url.Values{"pin": {"123456"}}, + expected: true, + }, + { + name: "PIN not submitted", + method: auth.MethodPIN, + formData: url.Values{}, + expected: false, + }, + { + name: "Password submitted", + method: auth.MethodPassword, + formData: url.Values{"password": {"secret"}}, + expected: true, + }, + { + name: "Password not submitted", + method: auth.MethodPassword, + formData: url.Values{}, + expected: false, + }, + { + name: "OIDC token in query", + method: auth.MethodOIDC, + query: url.Values{"session_token": {"abc123"}}, + expected: true, + }, + { + name: "OIDC token not in query", + method: auth.MethodOIDC, + query: url.Values{}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reqURL := "http://example.com/" + if len(tt.query) > 0 { + reqURL += "?" + tt.query.Encode() + } + + var body *strings.Reader + if len(tt.formData) > 0 { + body = strings.NewReader(tt.formData.Encode()) + } else { + body = strings.NewReader("") + } + + req := httptest.NewRequest(http.MethodPost, reqURL, body) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + result := wasCredentialSubmitted(req, tt.method) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestCheckIPRestrictions_UnparseableAddress(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + + err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1", + restrict.ParseFilter([]string{"10.0.0.0/8"}, nil, nil, nil)) + require.NoError(t, err) + + handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + tests := []struct { + name string + remoteAddr string + wantCode int + }{ + {"unparsable address denies", "not-an-ip:1234", http.StatusForbidden}, + {"empty address denies", "", http.StatusForbidden}, + {"allowed address passes", "10.1.2.3:5678", http.StatusOK}, + {"denied address blocked", "192.168.1.1:5678", http.StatusForbidden}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.RemoteAddr = tt.remoteAddr + req.Host = "example.com" + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + assert.Equal(t, tt.wantCode, rr.Code) + }) + } +} + +func TestCheckIPRestrictions_UsesCapturedDataClientIP(t *testing.T) { + // When CapturedData is set (by the access log middleware, which resolves + // trusted proxies), checkIPRestrictions should use that IP, not RemoteAddr. + mw := NewMiddleware(log.StandardLogger(), nil, nil) + + err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1", + restrict.ParseFilter([]string{"203.0.113.0/24"}, nil, nil, nil)) + require.NoError(t, err) + + handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + // RemoteAddr is a trusted proxy, but CapturedData has the real client IP. + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.RemoteAddr = "10.0.0.1:5000" + req.Host = "example.com" + + cd := proxy.NewCapturedData("") + cd.SetClientIP(netip.MustParseAddr("203.0.113.50")) + ctx := proxy.WithCapturedData(req.Context(), cd) + req = req.WithContext(ctx) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code, "should use CapturedData IP (203.0.113.50), not RemoteAddr (10.0.0.1)") + + // Same request but CapturedData has a blocked IP. + req2 := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req2.RemoteAddr = "203.0.113.50:5000" + req2.Host = "example.com" + + cd2 := proxy.NewCapturedData("") + cd2.SetClientIP(netip.MustParseAddr("10.0.0.1")) + ctx2 := proxy.WithCapturedData(req2.Context(), cd2) + req2 = req2.WithContext(ctx2) + + rr2 := httptest.NewRecorder() + handler.ServeHTTP(rr2, req2) + assert.Equal(t, http.StatusForbidden, rr2.Code, "should use CapturedData IP (10.0.0.1), not RemoteAddr (203.0.113.50)") +} + +func TestCheckIPRestrictions_NilGeoWithCountryRules(t *testing.T) { + // Geo is nil, country restrictions are configured: must deny (fail-close). + mw := NewMiddleware(log.StandardLogger(), nil, nil) + + err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1", + restrict.ParseFilter(nil, nil, []string{"US"}, nil)) + require.NoError(t, err) + + handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.RemoteAddr = "1.2.3.4:5678" + req.Host = "example.com" + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusForbidden, rr.Code, "country restrictions with nil geo must deny") +} + +// mockAuthenticator is a minimal mock for the authenticator gRPC interface +// used by the Header scheme. +type mockAuthenticator struct { + fn func(ctx context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) +} + +func (m *mockAuthenticator) Authenticate(ctx context.Context, in *proto.AuthenticateRequest, _ ...grpc.CallOption) (*proto.AuthenticateResponse, error) { + return m.fn(ctx, in) +} + +// newHeaderSchemeWithToken creates a Header scheme backed by a mock that +// returns a signed session token when the expected header value is provided. +func newHeaderSchemeWithToken(t *testing.T, kp *sessionkey.KeyPair, headerName, expectedValue string) Header { + t.Helper() + token, err := sessionkey.SignToken(kp.PrivateKey, "header-user", "example.com", auth.MethodHeader, time.Hour) + require.NoError(t, err) + + mock := &mockAuthenticator{fn: func(_ context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) { + ha := req.GetHeaderAuth() + if ha != nil && ha.GetHeaderValue() == expectedValue { + return &proto.AuthenticateResponse{Success: true, SessionToken: token}, nil + } + return &proto.AuthenticateResponse{Success: false}, nil + }} + return NewHeader(mock, "svc1", "acc1", headerName) +} + +func TestProtect_HeaderAuth_ForwardsOnSuccess(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key") + require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil)) + + var backendCalled bool + capturedData := proxy.NewCapturedData("") + handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + backendCalled = true + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/path", nil) + req.Header.Set("X-API-Key", "secret-key") + req = req.WithContext(proxy.WithCapturedData(req.Context(), capturedData)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.True(t, backendCalled, "backend should be called directly for header auth (no redirect)") + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "ok", rec.Body.String()) + + // Session cookie should be set. + var sessionCookie *http.Cookie + for _, c := range rec.Result().Cookies() { + if c.Name == auth.SessionCookieName { + sessionCookie = c + break + } + } + require.NotNil(t, sessionCookie, "session cookie should be set after successful header auth") + assert.True(t, sessionCookie.HttpOnly) + assert.True(t, sessionCookie.Secure) + + assert.Equal(t, "header-user", capturedData.GetUserID()) + assert.Equal(t, "header", capturedData.GetAuthMethod()) +} + +func TestProtect_HeaderAuth_MissingHeaderFallsThrough(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key") + // Also add a PIN scheme so we can verify fallthrough behavior. + pinScheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr, pinScheme}, kp.PublicKey, time.Hour, "acc1", "svc1", nil)) + + handler := mw.Protect(newPassthroughHandler()) + + // No X-API-Key header: should fall through to PIN login page (401). + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code, "missing header should fall through to login page") +} + +func TestProtect_HeaderAuth_WrongValueReturns401(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + mock := &mockAuthenticator{fn: func(_ context.Context, _ *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) { + return &proto.AuthenticateResponse{Success: false}, nil + }} + hdr := NewHeader(mock, "svc1", "acc1", "X-API-Key") + require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil)) + + capturedData := proxy.NewCapturedData("") + handler := mw.Protect(newPassthroughHandler()) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.Header.Set("X-API-Key", "wrong-key") + req = req.WithContext(proxy.WithCapturedData(req.Context(), capturedData)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Equal(t, "header", capturedData.GetAuthMethod()) +} + +func TestProtect_HeaderAuth_InfraErrorReturns502(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + mock := &mockAuthenticator{fn: func(_ context.Context, _ *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) { + return nil, errors.New("gRPC unavailable") + }} + hdr := NewHeader(mock, "svc1", "acc1", "X-API-Key") + require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil)) + + handler := mw.Protect(newPassthroughHandler()) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.Header.Set("X-API-Key", "some-key") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadGateway, rec.Code) +} + +func TestProtect_HeaderAuth_SubsequentRequestUsesSessionCookie(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key") + require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil)) + + handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + // First request with header auth. + req1 := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req1.Header.Set("X-API-Key", "secret-key") + req1 = req1.WithContext(proxy.WithCapturedData(req1.Context(), proxy.NewCapturedData(""))) + rec1 := httptest.NewRecorder() + handler.ServeHTTP(rec1, req1) + require.Equal(t, http.StatusOK, rec1.Code) + + // Extract session cookie. + var sessionCookie *http.Cookie + for _, c := range rec1.Result().Cookies() { + if c.Name == auth.SessionCookieName { + sessionCookie = c + break + } + } + require.NotNil(t, sessionCookie) + + // Second request with only the session cookie (no header). + capturedData2 := proxy.NewCapturedData("") + req2 := httptest.NewRequest(http.MethodGet, "http://example.com/other", nil) + req2.AddCookie(sessionCookie) + req2 = req2.WithContext(proxy.WithCapturedData(req2.Context(), capturedData2)) + rec2 := httptest.NewRecorder() + handler.ServeHTTP(rec2, req2) + + assert.Equal(t, http.StatusOK, rec2.Code) + assert.Equal(t, "header-user", capturedData2.GetUserID()) + assert.Equal(t, "header", capturedData2.GetAuthMethod()) +} diff --git a/proxy/internal/auth/oidc.go b/proxy/internal/auth/oidc.go new file mode 100644 index 000000000..a60e6437a --- /dev/null +++ b/proxy/internal/auth/oidc.go @@ -0,0 +1,66 @@ +package auth + +import ( + "context" + "fmt" + "net/http" + "net/url" + + "google.golang.org/grpc" + + "github.com/netbirdio/netbird/proxy/auth" + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/shared/management/proto" +) + +type urlGenerator interface { + GetOIDCURL(context.Context, *proto.GetOIDCURLRequest, ...grpc.CallOption) (*proto.GetOIDCURLResponse, error) +} + +type OIDC struct { + id types.ServiceID + accountId types.AccountID + forwardedProto string + client urlGenerator +} + +// NewOIDC creates a new OIDC authentication scheme +func NewOIDC(client urlGenerator, id types.ServiceID, accountId types.AccountID, forwardedProto string) OIDC { + return OIDC{ + id: id, + accountId: accountId, + forwardedProto: forwardedProto, + client: client, + } +} + +func (OIDC) Type() auth.Method { + return auth.MethodOIDC +} + +// Authenticate checks for an OIDC session token or obtains the OIDC redirect URL. +func (o OIDC) Authenticate(r *http.Request) (string, string, error) { + // Check for the session_token query param (from OIDC redirects). + // The management server passes the token in the URL because it cannot set + // cookies for the proxy's domain (cookies are domain-scoped per RFC 6265). + if token := r.URL.Query().Get("session_token"); token != "" { + return token, "", nil + } + + redirectURL := &url.URL{ + Scheme: auth.ResolveProto(o.forwardedProto, r.TLS), + Host: r.Host, + Path: r.URL.Path, + } + + res, err := o.client.GetOIDCURL(r.Context(), &proto.GetOIDCURLRequest{ + Id: string(o.id), + AccountId: string(o.accountId), + RedirectUrl: redirectURL.String(), + }) + if err != nil { + return "", "", fmt.Errorf("get OIDC URL: %w", err) + } + + return "", res.GetUrl(), nil +} diff --git a/proxy/internal/auth/password.go b/proxy/internal/auth/password.go new file mode 100644 index 000000000..6a7eda3e1 --- /dev/null +++ b/proxy/internal/auth/password.go @@ -0,0 +1,63 @@ +package auth + +import ( + "fmt" + "net/http" + + "github.com/netbirdio/netbird/proxy/auth" + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/shared/management/proto" +) + +const passwordFormId = "password" + +type Password struct { + id types.ServiceID + accountId types.AccountID + client authenticator +} + +func NewPassword(client authenticator, id types.ServiceID, accountId types.AccountID) Password { + return Password{ + id: id, + accountId: accountId, + client: client, + } +} + +func (Password) Type() auth.Method { + return auth.MethodPassword +} + +// Authenticate attempts to authenticate the request using a form +// value passed in the request. +// If authentication fails, the required HTTP form ID is returned +// so that it can be injected into a request from the UI so that +// authentication may be successful. +func (p Password) Authenticate(r *http.Request) (string, string, error) { + password := r.FormValue(passwordFormId) + + if password == "" { + // No password submitted; return the form ID so the UI can prompt the user. + return "", passwordFormId, nil + } + + res, err := p.client.Authenticate(r.Context(), &proto.AuthenticateRequest{ + Id: string(p.id), + AccountId: string(p.accountId), + Request: &proto.AuthenticateRequest_Password{ + Password: &proto.PasswordRequest{ + Password: password, + }, + }, + }) + if err != nil { + return "", "", fmt.Errorf("authenticate password: %w", err) + } + + if res.GetSuccess() { + return res.GetSessionToken(), "", nil + } + + return "", passwordFormId, nil +} diff --git a/proxy/internal/auth/pin.go b/proxy/internal/auth/pin.go new file mode 100644 index 000000000..4d08f3dc6 --- /dev/null +++ b/proxy/internal/auth/pin.go @@ -0,0 +1,63 @@ +package auth + +import ( + "fmt" + "net/http" + + "github.com/netbirdio/netbird/proxy/auth" + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/shared/management/proto" +) + +const pinFormId = "pin" + +type Pin struct { + id types.ServiceID + accountId types.AccountID + client authenticator +} + +func NewPin(client authenticator, id types.ServiceID, accountId types.AccountID) Pin { + return Pin{ + id: id, + accountId: accountId, + client: client, + } +} + +func (Pin) Type() auth.Method { + return auth.MethodPIN +} + +// Authenticate attempts to authenticate the request using a form +// value passed in the request. +// If authentication fails, the required HTTP form ID is returned +// so that it can be injected into a request from the UI so that +// authentication may be successful. +func (p Pin) Authenticate(r *http.Request) (string, string, error) { + pin := r.FormValue(pinFormId) + + if pin == "" { + // No PIN submitted; return the form ID so the UI can prompt the user. + return "", pinFormId, nil + } + + res, err := p.client.Authenticate(r.Context(), &proto.AuthenticateRequest{ + Id: string(p.id), + AccountId: string(p.accountId), + Request: &proto.AuthenticateRequest_Pin{ + Pin: &proto.PinRequest{ + Pin: pin, + }, + }, + }) + if err != nil { + return "", "", fmt.Errorf("authenticate pin: %w", err) + } + + if res.GetSuccess() { + return res.GetSessionToken(), "", nil + } + + return "", pinFormId, nil +} diff --git a/proxy/internal/certwatch/watcher.go b/proxy/internal/certwatch/watcher.go new file mode 100644 index 000000000..6366a53c6 --- /dev/null +++ b/proxy/internal/certwatch/watcher.go @@ -0,0 +1,286 @@ +// Package certwatch watches TLS certificate files on disk and provides +// a hot-reloading GetCertificate callback for tls.Config. +package certwatch + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "path/filepath" + "sync" + "time" + + "github.com/fsnotify/fsnotify" + log "github.com/sirupsen/logrus" +) + +const ( + defaultPollInterval = 30 * time.Second + debounceDelay = 500 * time.Millisecond +) + +// Watcher monitors TLS certificate files on disk and caches the loaded +// certificate in memory. It detects changes via fsnotify (with a polling +// fallback for filesystems like NFS that lack inotify support) and +// reloads the certificate pair automatically. +type Watcher struct { + certPath string + keyPath string + + mu sync.RWMutex + cert *tls.Certificate + leaf *x509.Certificate + + pollInterval time.Duration + logger *log.Logger +} + +// NewWatcher creates a Watcher that monitors the given cert and key files. +// It performs an initial load of the certificate and returns an error +// if the initial load fails. +func NewWatcher(certPath, keyPath string, logger *log.Logger) (*Watcher, error) { + if logger == nil { + logger = log.StandardLogger() + } + + w := &Watcher{ + certPath: certPath, + keyPath: keyPath, + pollInterval: defaultPollInterval, + logger: logger, + } + + if err := w.reload(); err != nil { + return nil, fmt.Errorf("initial certificate load: %w", err) + } + + return w, nil +} + +// GetCertificate returns the current in-memory certificate. +// It is safe for concurrent use and compatible with tls.Config.GetCertificate. +func (w *Watcher) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) { + w.mu.RLock() + defer w.mu.RUnlock() + + return w.cert, nil +} + +// Leaf returns the parsed leaf certificate, or nil if not yet loaded. +func (w *Watcher) Leaf() *x509.Certificate { + w.mu.RLock() + defer w.mu.RUnlock() + return w.leaf +} + +// Watch starts watching for certificate file changes. It blocks until +// ctx is cancelled. It uses fsnotify for immediate detection and falls +// back to polling if fsnotify is unavailable (e.g. on NFS). +// Even with fsnotify active, a periodic poll runs as a safety net. +func (w *Watcher) Watch(ctx context.Context) { + // Watch the parent directory rather than individual files. Some volume + // mounts use an atomic symlink swap (..data -> timestamped dir), so + // watching the parent directory catches the link replacement. + certDir := filepath.Dir(w.certPath) + keyDir := filepath.Dir(w.keyPath) + + watcher, err := fsnotify.NewWatcher() + if err != nil { + w.logger.Warnf("fsnotify unavailable, using polling only: %v", err) + w.pollLoop(ctx) + return + } + defer func() { + if err := watcher.Close(); err != nil { + w.logger.Debugf("close fsnotify watcher: %v", err) + } + }() + + if err := watcher.Add(certDir); err != nil { + w.logger.Warnf("fsnotify watch on %s failed, using polling only: %v", certDir, err) + w.pollLoop(ctx) + return + } + + if keyDir != certDir { + if err := watcher.Add(keyDir); err != nil { + w.logger.Warnf("fsnotify watch on %s failed: %v", keyDir, err) + } + } + + w.logger.Infof("watching certificate files in %s", certDir) + w.fsnotifyLoop(ctx, watcher) +} + +func (w *Watcher) fsnotifyLoop(ctx context.Context, watcher *fsnotify.Watcher) { + certBase := filepath.Base(w.certPath) + keyBase := filepath.Base(w.keyPath) + + var debounce *time.Timer + defer func() { + if debounce != nil { + debounce.Stop() + } + }() + + // Periodic poll as a safety net for missed fsnotify events. + pollTicker := time.NewTicker(w.pollInterval) + defer pollTicker.Stop() + + for { + select { + case <-ctx.Done(): + return + + case event, ok := <-watcher.Events: + if !ok { + return + } + + base := filepath.Base(event.Name) + if !isRelevantFile(base, certBase, keyBase) { + w.logger.Debugf("fsnotify: ignoring event %s on %s", event.Op, event.Name) + continue + } + if !event.Has(fsnotify.Create) && !event.Has(fsnotify.Write) && !event.Has(fsnotify.Rename) { + w.logger.Debugf("fsnotify: ignoring op %s on %s", event.Op, base) + continue + } + + w.logger.Debugf("fsnotify: detected %s on %s, scheduling reload", event.Op, base) + + // Debounce: cert-manager may write cert and key as separate + // operations. Wait briefly to load both at once. + if debounce != nil { + debounce.Stop() + } + debounce = time.AfterFunc(debounceDelay, func() { + if ctx.Err() != nil { + return + } + w.tryReload() + }) + + case err, ok := <-watcher.Errors: + if !ok { + return + } + w.logger.Warnf("fsnotify error: %v", err) + + case <-pollTicker.C: + w.tryReload() + } + } +} + +func (w *Watcher) pollLoop(ctx context.Context) { + ticker := time.NewTicker(w.pollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + w.tryReload() + } + } +} + +// reload loads the certificate from disk and updates the in-memory cache. +func (w *Watcher) reload() error { + cert, err := tls.LoadX509KeyPair(w.certPath, w.keyPath) + if err != nil { + return err + } + + // Parse the leaf for comparison on subsequent reloads. + if cert.Leaf == nil && len(cert.Certificate) > 0 { + leaf, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + return fmt.Errorf("parse leaf certificate: %w", err) + } + cert.Leaf = leaf + } + + w.mu.Lock() + w.cert = &cert + w.leaf = cert.Leaf + w.mu.Unlock() + + w.logCertDetails("loaded certificate", cert.Leaf) + + return nil +} + +// tryReload attempts to reload the certificate. It skips the update +// if the certificate on disk is identical to the one in memory (same +// serial number and issuer) to avoid redundant log noise. +func (w *Watcher) tryReload() { + cert, err := tls.LoadX509KeyPair(w.certPath, w.keyPath) + if err != nil { + w.logger.Warnf("reload certificate: %v", err) + return + } + + if cert.Leaf == nil && len(cert.Certificate) > 0 { + leaf, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + w.logger.Warnf("parse reloaded leaf certificate: %v", err) + return + } + cert.Leaf = leaf + } + + w.mu.Lock() + + if w.leaf != nil && cert.Leaf != nil && + w.leaf.SerialNumber.Cmp(cert.Leaf.SerialNumber) == 0 && + w.leaf.Issuer.CommonName == cert.Leaf.Issuer.CommonName { + w.mu.Unlock() + return + } + + prev := w.leaf + w.cert = &cert + w.leaf = cert.Leaf + w.mu.Unlock() + + w.logCertChange(prev, cert.Leaf) +} + +func (w *Watcher) logCertDetails(msg string, leaf *x509.Certificate) { + if leaf == nil { + w.logger.Info(msg) + return + } + + w.logger.Infof("%s: subject=%q serial=%s SANs=%v notAfter=%s", + msg, + leaf.Subject.CommonName, + leaf.SerialNumber.Text(16), + leaf.DNSNames, + leaf.NotAfter.UTC().Format(time.RFC3339), + ) +} + +func (w *Watcher) logCertChange(prev, next *x509.Certificate) { + if prev == nil || next == nil { + w.logCertDetails("certificate reloaded from disk", next) + return + } + + w.logger.Infof("certificate reloaded from disk: subject=%q -> %q serial=%s -> %s notAfter=%s -> %s", + prev.Subject.CommonName, next.Subject.CommonName, + prev.SerialNumber.Text(16), next.SerialNumber.Text(16), + prev.NotAfter.UTC().Format(time.RFC3339), next.NotAfter.UTC().Format(time.RFC3339), + ) +} + +// isRelevantFile returns true if the changed file name is one we care about. +// This includes the cert/key files themselves and the ..data symlink used +// by atomic volume mounts. +func isRelevantFile(changed, certBase, keyBase string) bool { + return changed == certBase || changed == keyBase || changed == "..data" +} diff --git a/proxy/internal/certwatch/watcher_test.go b/proxy/internal/certwatch/watcher_test.go new file mode 100644 index 000000000..06b0a4bb8 --- /dev/null +++ b/proxy/internal/certwatch/watcher_test.go @@ -0,0 +1,292 @@ +package certwatch + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func generateSelfSignedCert(t *testing.T, serial int64) (certPEM, keyPEM []byte) { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(serial), + Subject: pkix.Name{CommonName: "test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + + certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + + keyDER, err := x509.MarshalECPrivateKey(key) + require.NoError(t, err) + keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + + return certPEM, keyPEM +} + +func writeCert(t *testing.T, dir string, certPEM, keyPEM []byte) { + t.Helper() + + require.NoError(t, os.WriteFile(filepath.Join(dir, "tls.crt"), certPEM, 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "tls.key"), keyPEM, 0o600)) +} + +func TestNewWatcher(t *testing.T) { + dir := t.TempDir() + certPEM, keyPEM := generateSelfSignedCert(t, 1) + writeCert(t, dir, certPEM, keyPEM) + + w, err := NewWatcher( + filepath.Join(dir, "tls.crt"), + filepath.Join(dir, "tls.key"), + nil, + ) + require.NoError(t, err) + + cert, err := w.GetCertificate(nil) + require.NoError(t, err) + require.NotNil(t, cert) + assert.Equal(t, int64(1), cert.Leaf.SerialNumber.Int64()) +} + +func TestNewWatcherMissingFiles(t *testing.T) { + dir := t.TempDir() + + _, err := NewWatcher( + filepath.Join(dir, "tls.crt"), + filepath.Join(dir, "tls.key"), + nil, + ) + assert.Error(t, err) +} + +func TestReload(t *testing.T) { + dir := t.TempDir() + certPEM1, keyPEM1 := generateSelfSignedCert(t, 100) + writeCert(t, dir, certPEM1, keyPEM1) + + w, err := NewWatcher( + filepath.Join(dir, "tls.crt"), + filepath.Join(dir, "tls.key"), + nil, + ) + require.NoError(t, err) + + cert1, err := w.GetCertificate(nil) + require.NoError(t, err) + assert.Equal(t, int64(100), cert1.Leaf.SerialNumber.Int64()) + + // Write a new cert with a different serial. + certPEM2, keyPEM2 := generateSelfSignedCert(t, 200) + writeCert(t, dir, certPEM2, keyPEM2) + + // Manually trigger reload. + w.tryReload() + + cert2, err := w.GetCertificate(nil) + require.NoError(t, err) + assert.Equal(t, int64(200), cert2.Leaf.SerialNumber.Int64()) +} + +func TestTryReloadSkipsUnchanged(t *testing.T) { + dir := t.TempDir() + certPEM, keyPEM := generateSelfSignedCert(t, 42) + writeCert(t, dir, certPEM, keyPEM) + + w, err := NewWatcher( + filepath.Join(dir, "tls.crt"), + filepath.Join(dir, "tls.key"), + nil, + ) + require.NoError(t, err) + + cert1, err := w.GetCertificate(nil) + require.NoError(t, err) + + // Reload with same cert - pointer should remain the same. + w.tryReload() + + cert2, err := w.GetCertificate(nil) + require.NoError(t, err) + assert.Same(t, cert1, cert2, "cert pointer should not change when content is the same") +} + +func TestWatchDetectsChanges(t *testing.T) { + dir := t.TempDir() + certPEM1, keyPEM1 := generateSelfSignedCert(t, 1) + writeCert(t, dir, certPEM1, keyPEM1) + + w, err := NewWatcher( + filepath.Join(dir, "tls.crt"), + filepath.Join(dir, "tls.key"), + nil, + ) + require.NoError(t, err) + + // Use a short poll interval for the test. + w.pollInterval = 100 * time.Millisecond + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go w.Watch(ctx) + + // Write new cert. + certPEM2, keyPEM2 := generateSelfSignedCert(t, 999) + writeCert(t, dir, certPEM2, keyPEM2) + + // Wait for the watcher to pick it up. + require.Eventually(t, func() bool { + cert, err := w.GetCertificate(nil) + if err != nil { + return false + } + return cert.Leaf.SerialNumber.Int64() == 999 + }, 5*time.Second, 50*time.Millisecond, "watcher should detect cert change") +} + +func TestIsRelevantFile(t *testing.T) { + assert.True(t, isRelevantFile("tls.crt", "tls.crt", "tls.key")) + assert.True(t, isRelevantFile("tls.key", "tls.crt", "tls.key")) + assert.True(t, isRelevantFile("..data", "tls.crt", "tls.key")) + assert.False(t, isRelevantFile("other.txt", "tls.crt", "tls.key")) +} + +// TestWatchSymlinkRotation simulates Kubernetes secret volume updates where +// the data directory is atomically swapped via a ..data symlink. +func TestWatchSymlinkRotation(t *testing.T) { + base := t.TempDir() + + // Create initial target directory with certs. + dir1 := filepath.Join(base, "dir1") + require.NoError(t, os.Mkdir(dir1, 0o755)) + certPEM1, keyPEM1 := generateSelfSignedCert(t, 1) + require.NoError(t, os.WriteFile(filepath.Join(dir1, "tls.crt"), certPEM1, 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir1, "tls.key"), keyPEM1, 0o600)) + + // Create ..data symlink pointing to dir1. + dataLink := filepath.Join(base, "..data") + require.NoError(t, os.Symlink(dir1, dataLink)) + + // Create tls.crt and tls.key as symlinks to ..data/{file}. + certLink := filepath.Join(base, "tls.crt") + keyLink := filepath.Join(base, "tls.key") + require.NoError(t, os.Symlink(filepath.Join(dataLink, "tls.crt"), certLink)) + require.NoError(t, os.Symlink(filepath.Join(dataLink, "tls.key"), keyLink)) + + w, err := NewWatcher(certLink, keyLink, nil) + require.NoError(t, err) + + cert, err := w.GetCertificate(nil) + require.NoError(t, err) + assert.Equal(t, int64(1), cert.Leaf.SerialNumber.Int64()) + + w.pollInterval = 100 * time.Millisecond + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go w.Watch(ctx) + + // Simulate k8s atomic rotation: create dir2, swap ..data symlink. + dir2 := filepath.Join(base, "dir2") + require.NoError(t, os.Mkdir(dir2, 0o755)) + certPEM2, keyPEM2 := generateSelfSignedCert(t, 777) + require.NoError(t, os.WriteFile(filepath.Join(dir2, "tls.crt"), certPEM2, 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir2, "tls.key"), keyPEM2, 0o600)) + + // Atomic swap: create temp link, then rename over ..data. + tmpLink := filepath.Join(base, "..data_tmp") + require.NoError(t, os.Symlink(dir2, tmpLink)) + require.NoError(t, os.Rename(tmpLink, dataLink)) + + require.Eventually(t, func() bool { + cert, err := w.GetCertificate(nil) + if err != nil { + return false + } + return cert.Leaf.SerialNumber.Int64() == 777 + }, 5*time.Second, 50*time.Millisecond, "watcher should detect symlink rotation") +} + +// TestPollLoopDetectsChanges verifies the poll-only fallback path works. +func TestPollLoopDetectsChanges(t *testing.T) { + dir := t.TempDir() + certPEM1, keyPEM1 := generateSelfSignedCert(t, 1) + writeCert(t, dir, certPEM1, keyPEM1) + + w, err := NewWatcher( + filepath.Join(dir, "tls.crt"), + filepath.Join(dir, "tls.key"), + nil, + ) + require.NoError(t, err) + + w.pollInterval = 100 * time.Millisecond + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Directly use pollLoop to test the fallback path. + go w.pollLoop(ctx) + + certPEM2, keyPEM2 := generateSelfSignedCert(t, 555) + writeCert(t, dir, certPEM2, keyPEM2) + + require.Eventually(t, func() bool { + cert, err := w.GetCertificate(nil) + if err != nil { + return false + } + return cert.Leaf.SerialNumber.Int64() == 555 + }, 5*time.Second, 50*time.Millisecond, "poll loop should detect cert change") +} + +func TestGetCertificateConcurrency(t *testing.T) { + dir := t.TempDir() + certPEM, keyPEM := generateSelfSignedCert(t, 1) + writeCert(t, dir, certPEM, keyPEM) + + w, err := NewWatcher( + filepath.Join(dir, "tls.crt"), + filepath.Join(dir, "tls.key"), + nil, + ) + require.NoError(t, err) + + // Hammer GetCertificate concurrently while reloading. + done := make(chan struct{}) + go func() { + for i := 0; i < 100; i++ { + w.tryReload() + } + close(done) + }() + + for i := 0; i < 1000; i++ { + cert, err := w.GetCertificate(&tls.ClientHelloInfo{}) + assert.NoError(t, err) + assert.NotNil(t, cert) + } + + <-done +} diff --git a/proxy/internal/conntrack/conn.go b/proxy/internal/conntrack/conn.go new file mode 100644 index 000000000..8446d638f --- /dev/null +++ b/proxy/internal/conntrack/conn.go @@ -0,0 +1,51 @@ +package conntrack + +import ( + "bufio" + "net" + "net/http" +) + +// trackedConn wraps a net.Conn and removes itself from the tracker on Close. +type trackedConn struct { + net.Conn + tracker *HijackTracker + host string +} + +func (c *trackedConn) Close() error { + c.tracker.remove(c) + return c.Conn.Close() +} + +// trackingWriter wraps an http.ResponseWriter and intercepts Hijack calls +// to replace the raw connection with a trackedConn that auto-deregisters. +type trackingWriter struct { + http.ResponseWriter + tracker *HijackTracker + host string +} + +func (w *trackingWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + hijacker, ok := w.ResponseWriter.(http.Hijacker) + if !ok { + return nil, nil, http.ErrNotSupported + } + conn, buf, err := hijacker.Hijack() + if err != nil { + return nil, nil, err + } + tc := &trackedConn{Conn: conn, tracker: w.tracker, host: w.host} + w.tracker.add(tc) + return tc, buf, nil +} + +func (w *trackingWriter) Flush() { + if flusher, ok := w.ResponseWriter.(http.Flusher); ok { + flusher.Flush() + } +} + +func (w *trackingWriter) Unwrap() http.ResponseWriter { + return w.ResponseWriter +} diff --git a/proxy/internal/conntrack/hijacked.go b/proxy/internal/conntrack/hijacked.go new file mode 100644 index 000000000..911f93f3d --- /dev/null +++ b/proxy/internal/conntrack/hijacked.go @@ -0,0 +1,96 @@ +package conntrack + +import ( + "net/http" + "sync" +) + +// HijackTracker tracks connections that have been hijacked (e.g. WebSocket +// upgrades). http.Server.Shutdown does not close hijacked connections, so +// they must be tracked and closed explicitly during graceful shutdown. +// +// Connections are indexed by the request Host so they can be closed +// per-domain when a service mapping is removed. +// +// Use Middleware as the outermost HTTP middleware to ensure hijacked +// connections are tracked and automatically deregistered when closed. +type HijackTracker struct { + mu sync.Mutex + conns map[*trackedConn]struct{} +} + +// Middleware returns an HTTP middleware that wraps the ResponseWriter so that +// hijacked connections are tracked and automatically deregistered from the +// tracker when closed. This should be the outermost middleware in the chain. +func (t *HijackTracker) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(&trackingWriter{ + ResponseWriter: w, + tracker: t, + host: hostOnly(r.Host), + }, r) + }) +} + +// CloseAll closes all tracked hijacked connections and returns the count. +func (t *HijackTracker) CloseAll() int { + t.mu.Lock() + conns := t.conns + t.conns = nil + t.mu.Unlock() + + for tc := range conns { + _ = tc.Conn.Close() + } + return len(conns) +} + +// CloseByHost closes all tracked hijacked connections for the given host +// and returns the number of connections closed. +func (t *HijackTracker) CloseByHost(host string) int { + host = hostOnly(host) + t.mu.Lock() + var toClose []*trackedConn + for tc := range t.conns { + if tc.host == host { + toClose = append(toClose, tc) + } + } + for _, tc := range toClose { + delete(t.conns, tc) + } + t.mu.Unlock() + + for _, tc := range toClose { + _ = tc.Conn.Close() + } + return len(toClose) +} + +func (t *HijackTracker) add(tc *trackedConn) { + t.mu.Lock() + if t.conns == nil { + t.conns = make(map[*trackedConn]struct{}) + } + t.conns[tc] = struct{}{} + t.mu.Unlock() +} + +func (t *HijackTracker) remove(tc *trackedConn) { + t.mu.Lock() + delete(t.conns, tc) + t.mu.Unlock() +} + +// hostOnly strips the port from a host:port string. +func hostOnly(hostport string) string { + for i := len(hostport) - 1; i >= 0; i-- { + if hostport[i] == ':' { + return hostport[:i] + } + if hostport[i] < '0' || hostport[i] > '9' { + return hostport + } + } + return hostport +} diff --git a/proxy/internal/conntrack/hijacked_test.go b/proxy/internal/conntrack/hijacked_test.go new file mode 100644 index 000000000..9ceefff78 --- /dev/null +++ b/proxy/internal/conntrack/hijacked_test.go @@ -0,0 +1,142 @@ +package conntrack + +import ( + "bufio" + "net" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeHijackWriter implements http.ResponseWriter and http.Hijacker for testing. +type fakeHijackWriter struct { + http.ResponseWriter + conn net.Conn +} + +func (f *fakeHijackWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + rw := bufio.NewReadWriter(bufio.NewReader(f.conn), bufio.NewWriter(f.conn)) + return f.conn, rw, nil +} + +func TestCloseByHost(t *testing.T) { + var tracker HijackTracker + + // Simulate hijacking two connections for different hosts. + connA1, connA2 := net.Pipe() + defer connA2.Close() + connB1, connB2 := net.Pipe() + defer connB2.Close() + + twA := &trackingWriter{ + ResponseWriter: httptest.NewRecorder(), + tracker: &tracker, + host: "a.example.com", + } + twB := &trackingWriter{ + ResponseWriter: httptest.NewRecorder(), + tracker: &tracker, + host: "b.example.com", + } + + // Use fakeHijackWriter to provide the Hijack method. + twA.ResponseWriter = &fakeHijackWriter{ResponseWriter: twA.ResponseWriter, conn: connA1} + twB.ResponseWriter = &fakeHijackWriter{ResponseWriter: twB.ResponseWriter, conn: connB1} + + _, _, err := twA.Hijack() + require.NoError(t, err) + _, _, err = twB.Hijack() + require.NoError(t, err) + + tracker.mu.Lock() + assert.Equal(t, 2, len(tracker.conns), "should track 2 connections") + tracker.mu.Unlock() + + // Close only host A. + n := tracker.CloseByHost("a.example.com") + assert.Equal(t, 1, n, "should close 1 connection for host A") + + tracker.mu.Lock() + assert.Equal(t, 1, len(tracker.conns), "should have 1 remaining connection") + tracker.mu.Unlock() + + // Verify host A's conn is actually closed. + buf := make([]byte, 1) + _, err = connA2.Read(buf) + assert.Error(t, err, "host A pipe should be closed") + + // Host B should still be alive. + go func() { _, _ = connB1.Write([]byte("x")) }() + + // Close all remaining. + n = tracker.CloseAll() + assert.Equal(t, 1, n, "should close remaining 1 connection") + + tracker.mu.Lock() + assert.Equal(t, 0, len(tracker.conns), "should have 0 connections after CloseAll") + tracker.mu.Unlock() +} + +func TestCloseAll(t *testing.T) { + var tracker HijackTracker + + for range 5 { + c1, c2 := net.Pipe() + defer c2.Close() + tc := &trackedConn{Conn: c1, tracker: &tracker, host: "test.com"} + tracker.add(tc) + } + + tracker.mu.Lock() + assert.Equal(t, 5, len(tracker.conns)) + tracker.mu.Unlock() + + n := tracker.CloseAll() + assert.Equal(t, 5, n) + + // Double CloseAll is safe. + n = tracker.CloseAll() + assert.Equal(t, 0, n) +} + +func TestTrackedConn_AutoDeregister(t *testing.T) { + var tracker HijackTracker + + c1, c2 := net.Pipe() + defer c2.Close() + + tc := &trackedConn{Conn: c1, tracker: &tracker, host: "auto.com"} + tracker.add(tc) + + tracker.mu.Lock() + assert.Equal(t, 1, len(tracker.conns)) + tracker.mu.Unlock() + + // Close the tracked conn: should auto-deregister. + require.NoError(t, tc.Close()) + + tracker.mu.Lock() + assert.Equal(t, 0, len(tracker.conns), "should auto-deregister on close") + tracker.mu.Unlock() +} + +func TestHostOnly(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"example.com:443", "example.com"}, + {"example.com", "example.com"}, + {"127.0.0.1:8080", "127.0.0.1"}, + {"[::1]:443", "[::1]"}, + {"", ""}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + assert.Equal(t, tt.want, hostOnly(tt.input)) + }) + } +} diff --git a/proxy/internal/debug/client.go b/proxy/internal/debug/client.go new file mode 100644 index 000000000..01b0bc8e6 --- /dev/null +++ b/proxy/internal/debug/client.go @@ -0,0 +1,388 @@ +// Package debug provides HTTP debug endpoints and CLI client for the proxy server. +package debug + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// StatusFilters contains filter options for status queries. +type StatusFilters struct { + IPs []string + Names []string + Status string + ConnectionType string +} + +// Client provides CLI access to debug endpoints. +type Client struct { + baseURL string + jsonOutput bool + httpClient *http.Client + out io.Writer +} + +// NewClient creates a new debug client. +func NewClient(baseURL string, jsonOutput bool, out io.Writer) *Client { + if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") { + baseURL = "http://" + baseURL + } + baseURL = strings.TrimSuffix(baseURL, "/") + + return &Client{ + baseURL: baseURL, + jsonOutput: jsonOutput, + out: out, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// Health fetches the health status. +func (c *Client) Health(ctx context.Context) error { + return c.fetchAndPrint(ctx, "/debug/health", c.printHealth) +} + +func (c *Client) printHealth(data map[string]any) { + _, _ = fmt.Fprintf(c.out, "Status: %v\n", data["status"]) + _, _ = fmt.Fprintf(c.out, "Uptime: %v\n", data["uptime"]) + _, _ = fmt.Fprintf(c.out, "Management Connected: %s\n", boolIcon(data["management_connected"])) + _, _ = fmt.Fprintf(c.out, "All Clients Healthy: %s\n", boolIcon(data["all_clients_healthy"])) + + total, _ := data["certs_total"].(float64) + ready, _ := data["certs_ready"].(float64) + pending, _ := data["certs_pending"].(float64) + failed, _ := data["certs_failed"].(float64) + if total > 0 { + _, _ = fmt.Fprintf(c.out, "Certificates: %d ready, %d pending, %d failed (%d total)\n", + int(ready), int(pending), int(failed), int(total)) + } + if domains, ok := data["certs_ready_domains"].([]any); ok && len(domains) > 0 { + _, _ = fmt.Fprintf(c.out, " Ready:\n") + for _, d := range domains { + _, _ = fmt.Fprintf(c.out, " %v\n", d) + } + } + if domains, ok := data["certs_pending_domains"].([]any); ok && len(domains) > 0 { + _, _ = fmt.Fprintf(c.out, " Pending:\n") + for _, d := range domains { + _, _ = fmt.Fprintf(c.out, " %v\n", d) + } + } + if domains, ok := data["certs_failed_domains"].(map[string]any); ok && len(domains) > 0 { + _, _ = fmt.Fprintf(c.out, " Failed:\n") + for d, errMsg := range domains { + _, _ = fmt.Fprintf(c.out, " %s: %v\n", d, errMsg) + } + } + + c.printHealthClients(data) +} + +func (c *Client) printHealthClients(data map[string]any) { + clients, ok := data["clients"].(map[string]any) + if !ok || len(clients) == 0 { + return + } + + _, _ = fmt.Fprintf(c.out, "\n%-38s %-9s %-7s %-8s %-8s %-16s %s\n", + "ACCOUNT ID", "HEALTHY", "MGMT", "SIGNAL", "RELAYS", "PEERS (P2P/RLY)", "DEGRADED") + _, _ = fmt.Fprintln(c.out, strings.Repeat("-", 110)) + + for accountID, v := range clients { + ch, ok := v.(map[string]any) + if !ok { + continue + } + + healthy := boolIcon(ch["healthy"]) + mgmt := boolIcon(ch["management_connected"]) + signal := boolIcon(ch["signal_connected"]) + + relaysConn, _ := ch["relays_connected"].(float64) + relaysTotal, _ := ch["relays_total"].(float64) + relays := fmt.Sprintf("%d/%d", int(relaysConn), int(relaysTotal)) + + peersConnected, _ := ch["peers_connected"].(float64) + peersTotal, _ := ch["peers_total"].(float64) + peersP2P, _ := ch["peers_p2p"].(float64) + peersRelayed, _ := ch["peers_relayed"].(float64) + peersDegraded, _ := ch["peers_degraded"].(float64) + peers := fmt.Sprintf("%d/%d (%d/%d)", int(peersConnected), int(peersTotal), int(peersP2P), int(peersRelayed)) + degraded := fmt.Sprintf("%d", int(peersDegraded)) + + _, _ = fmt.Fprintf(c.out, "%-38s %-9s %-7s %-8s %-8s %-16s %s", accountID, healthy, mgmt, signal, relays, peers, degraded) + if errMsg, ok := ch["error"].(string); ok && errMsg != "" { + _, _ = fmt.Fprintf(c.out, " (%s)", errMsg) + } + _, _ = fmt.Fprintln(c.out) + } +} + +func boolIcon(v any) string { + b, ok := v.(bool) + if !ok { + return "?" + } + if b { + return "yes" + } + return "no" +} + +// ListClients fetches the list of all clients. +func (c *Client) ListClients(ctx context.Context) error { + return c.fetchAndPrint(ctx, "/debug/clients", c.printClients) +} + +func (c *Client) printClients(data map[string]any) { + _, _ = fmt.Fprintf(c.out, "Uptime: %v\n", data["uptime"]) + _, _ = fmt.Fprintf(c.out, "Clients: %v\n\n", data["client_count"]) + + clients, ok := data["clients"].([]any) + if !ok || len(clients) == 0 { + _, _ = fmt.Fprintln(c.out, "No clients connected.") + return + } + + _, _ = fmt.Fprintf(c.out, "%-38s %-12s %-40s %s\n", "ACCOUNT ID", "AGE", "SERVICES", "HAS CLIENT") + _, _ = fmt.Fprintln(c.out, strings.Repeat("-", 110)) + + for _, item := range clients { + c.printClientRow(item) + } +} + +func (c *Client) printClientRow(item any) { + client, ok := item.(map[string]any) + if !ok { + return + } + + services := c.extractServiceKeys(client) + hasClient := "no" + if hc, ok := client["has_client"].(bool); ok && hc { + hasClient = "yes" + } + + _, _ = fmt.Fprintf(c.out, "%-38s %-12v %s %s\n", + client["account_id"], + client["age"], + services, + hasClient, + ) +} + +func (c *Client) extractServiceKeys(client map[string]any) string { + d, ok := client["service_keys"].([]any) + if !ok || len(d) == 0 { + return "-" + } + + parts := make([]string, len(d)) + for i, key := range d { + parts[i] = fmt.Sprint(key) + } + return strings.Join(parts, ", ") +} + +// ClientStatus fetches the status of a specific client. +func (c *Client) ClientStatus(ctx context.Context, accountID string, filters StatusFilters) error { + params := url.Values{} + if len(filters.IPs) > 0 { + params.Set("filter-by-ips", strings.Join(filters.IPs, ",")) + } + if len(filters.Names) > 0 { + params.Set("filter-by-names", strings.Join(filters.Names, ",")) + } + if filters.Status != "" { + params.Set("filter-by-status", filters.Status) + } + if filters.ConnectionType != "" { + params.Set("filter-by-connection-type", filters.ConnectionType) + } + + path := "/debug/clients/" + url.PathEscape(accountID) + if len(params) > 0 { + path += "?" + params.Encode() + } + return c.fetchAndPrint(ctx, path, c.printClientStatus) +} + +func (c *Client) printClientStatus(data map[string]any) { + _, _ = fmt.Fprintf(c.out, "Account: %v\n\n", data["account_id"]) + if status, ok := data["status"].(string); ok { + _, _ = fmt.Fprint(c.out, status) + } +} + +// ClientSyncResponse fetches the sync response of a specific client. +func (c *Client) ClientSyncResponse(ctx context.Context, accountID string) error { + path := "/debug/clients/" + url.PathEscape(accountID) + "/syncresponse" + return c.fetchAndPrintJSON(ctx, path) +} + +// PingTCP performs a TCP ping through a client. +func (c *Client) PingTCP(ctx context.Context, accountID, host string, port int, timeout string) error { + params := url.Values{} + params.Set("host", host) + params.Set("port", fmt.Sprintf("%d", port)) + if timeout != "" { + params.Set("timeout", timeout) + } + + path := fmt.Sprintf("/debug/clients/%s/pingtcp?%s", url.PathEscape(accountID), params.Encode()) + return c.fetchAndPrint(ctx, path, c.printPingResult) +} + +func (c *Client) printPingResult(data map[string]any) { + success, _ := data["success"].(bool) + if success { + _, _ = fmt.Fprintf(c.out, "Success: %v:%v\n", data["host"], data["port"]) + _, _ = fmt.Fprintf(c.out, "Latency: %v\n", data["latency"]) + } else { + _, _ = fmt.Fprintf(c.out, "Failed: %v:%v\n", data["host"], data["port"]) + c.printError(data) + } +} + +// SetLogLevel sets the log level of a specific client. +func (c *Client) SetLogLevel(ctx context.Context, accountID, level string) error { + params := url.Values{} + params.Set("level", level) + + path := fmt.Sprintf("/debug/clients/%s/loglevel?%s", url.PathEscape(accountID), params.Encode()) + return c.fetchAndPrint(ctx, path, c.printLogLevelResult) +} + +func (c *Client) printLogLevelResult(data map[string]any) { + success, _ := data["success"].(bool) + if success { + _, _ = fmt.Fprintf(c.out, "Log level set to: %v\n", data["level"]) + } else { + _, _ = fmt.Fprintln(c.out, "Failed to set log level") + c.printError(data) + } +} + +// StartClient starts a specific client. +func (c *Client) StartClient(ctx context.Context, accountID string) error { + path := "/debug/clients/" + url.PathEscape(accountID) + "/start" + return c.fetchAndPrint(ctx, path, c.printStartResult) +} + +func (c *Client) printStartResult(data map[string]any) { + success, _ := data["success"].(bool) + if success { + _, _ = fmt.Fprintln(c.out, "Client started") + } else { + _, _ = fmt.Fprintln(c.out, "Failed to start client") + c.printError(data) + } +} + +// StopClient stops a specific client. +func (c *Client) StopClient(ctx context.Context, accountID string) error { + path := "/debug/clients/" + url.PathEscape(accountID) + "/stop" + return c.fetchAndPrint(ctx, path, c.printStopResult) +} + +func (c *Client) printStopResult(data map[string]any) { + success, _ := data["success"].(bool) + if success { + _, _ = fmt.Fprintln(c.out, "Client stopped") + } else { + _, _ = fmt.Fprintln(c.out, "Failed to stop client") + c.printError(data) + } +} + +func (c *Client) printError(data map[string]any) { + if errMsg, ok := data["error"].(string); ok { + _, _ = fmt.Fprintf(c.out, "Error: %s\n", errMsg) + } +} + +func (c *Client) fetchAndPrint(ctx context.Context, path string, printer func(map[string]any)) error { + data, raw, err := c.fetch(ctx, path) + if err != nil { + return err + } + + if c.jsonOutput { + return c.writeJSON(data) + } + + if data != nil { + printer(data) + return nil + } + + _, _ = fmt.Fprintln(c.out, string(raw)) + return nil +} + +func (c *Client) fetchAndPrintJSON(ctx context.Context, path string) error { + data, raw, err := c.fetch(ctx, path) + if err != nil { + return err + } + + if data != nil { + return c.writeJSON(data) + } + + _, _ = fmt.Fprintln(c.out, string(raw)) + return nil +} + +func (c *Client) writeJSON(data map[string]any) error { + enc := json.NewEncoder(c.out) + enc.SetIndent("", " ") + return enc.Encode(data) +} + +func (c *Client) fetch(ctx context.Context, path string) (map[string]any, []byte, error) { + fullURL := c.baseURL + path + if !strings.Contains(path, "format=json") { + if strings.Contains(path, "?") { + fullURL += "&format=json" + } else { + fullURL += "?format=json" + } + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + if err != nil { + return nil, nil, fmt.Errorf("create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, nil, fmt.Errorf("server error (%d): %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var data map[string]any + if err := json.Unmarshal(body, &data); err != nil { + return nil, body, nil + } + + return data, body, nil +} diff --git a/proxy/internal/debug/client_test.go b/proxy/internal/debug/client_test.go new file mode 100644 index 000000000..0d627a94e --- /dev/null +++ b/proxy/internal/debug/client_test.go @@ -0,0 +1,71 @@ +package debug + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPrintHealth_WithCertsAndClients(t *testing.T) { + var buf bytes.Buffer + c := NewClient("localhost:8444", false, &buf) + + data := map[string]any{ + "status": "ok", + "uptime": "1h30m", + "management_connected": true, + "all_clients_healthy": true, + "certs_total": float64(3), + "certs_ready": float64(2), + "certs_pending": float64(1), + "certs_failed": float64(0), + "certs_ready_domains": []any{"a.example.com", "b.example.com"}, + "certs_pending_domains": []any{"c.example.com"}, + "clients": map[string]any{ + "acc-1": map[string]any{ + "healthy": true, + "management_connected": true, + "signal_connected": true, + "relays_connected": float64(1), + "relays_total": float64(2), + "peers_connected": float64(3), + "peers_total": float64(5), + "peers_p2p": float64(2), + "peers_relayed": float64(1), + "peers_degraded": float64(0), + }, + }, + } + + c.printHealth(data) + out := buf.String() + + assert.Contains(t, out, "Status: ok") + assert.Contains(t, out, "Uptime: 1h30m") + assert.Contains(t, out, "yes") // management_connected + assert.Contains(t, out, "2 ready, 1 pending, 0 failed (3 total)") + assert.Contains(t, out, "a.example.com") + assert.Contains(t, out, "c.example.com") + assert.Contains(t, out, "acc-1") +} + +func TestPrintHealth_Minimal(t *testing.T) { + var buf bytes.Buffer + c := NewClient("localhost:8444", false, &buf) + + data := map[string]any{ + "status": "ok", + "uptime": "5m", + "management_connected": false, + "all_clients_healthy": false, + } + + c.printHealth(data) + out := buf.String() + + assert.Contains(t, out, "Status: ok") + assert.Contains(t, out, "Uptime: 5m") + assert.NotContains(t, out, "Certificates") + assert.NotContains(t, out, "ACCOUNT ID") +} diff --git a/proxy/internal/debug/handler.go b/proxy/internal/debug/handler.go new file mode 100644 index 000000000..237010922 --- /dev/null +++ b/proxy/internal/debug/handler.go @@ -0,0 +1,712 @@ +// Package debug provides HTTP debug endpoints for the proxy server. +package debug + +import ( + "cmp" + "context" + "embed" + "encoding/json" + "fmt" + "html/template" + "maps" + "net/http" + "slices" + "strconv" + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" + "google.golang.org/protobuf/encoding/protojson" + + nbembed "github.com/netbirdio/netbird/client/embed" + nbstatus "github.com/netbirdio/netbird/client/status" + "github.com/netbirdio/netbird/proxy/internal/health" + "github.com/netbirdio/netbird/proxy/internal/roundtrip" + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/version" +) + +//go:embed templates/*.html +var templateFS embed.FS + +const defaultPingTimeout = 10 * time.Second + +// formatDuration formats a duration with 2 decimal places using appropriate units. +func formatDuration(d time.Duration) string { + switch { + case d >= time.Hour: + return fmt.Sprintf("%.2fh", d.Hours()) + case d >= time.Minute: + return fmt.Sprintf("%.2fm", d.Minutes()) + case d >= time.Second: + return fmt.Sprintf("%.2fs", d.Seconds()) + case d >= time.Millisecond: + return fmt.Sprintf("%.2fms", float64(d.Microseconds())/1000) + case d >= time.Microsecond: + return fmt.Sprintf("%.2fµs", float64(d.Nanoseconds())/1000) + default: + return fmt.Sprintf("%dns", d.Nanoseconds()) + } +} + +func sortedAccountIDs(m map[types.AccountID]roundtrip.ClientDebugInfo) []types.AccountID { + return slices.Sorted(maps.Keys(m)) +} + +// clientProvider provides access to NetBird clients. +type clientProvider interface { + GetClient(accountID types.AccountID) (*nbembed.Client, bool) + ListClientsForDebug() map[types.AccountID]roundtrip.ClientDebugInfo +} + +// healthChecker provides health probe state. +type healthChecker interface { + ReadinessProbe() bool + StartupProbe(ctx context.Context) bool + CheckClientsConnected(ctx context.Context) (bool, map[types.AccountID]health.ClientHealth) +} + +type certStatus interface { + TotalDomains() int + PendingDomains() []string + ReadyDomains() []string + FailedDomains() map[string]string +} + +// Handler provides HTTP debug endpoints. +type Handler struct { + provider clientProvider + health healthChecker + certStatus certStatus + logger *log.Logger + startTime time.Time + templates *template.Template + templateMu sync.RWMutex +} + +// NewHandler creates a new debug handler. +func NewHandler(provider clientProvider, healthChecker healthChecker, logger *log.Logger) *Handler { + if logger == nil { + logger = log.StandardLogger() + } + h := &Handler{ + provider: provider, + health: healthChecker, + logger: logger, + startTime: time.Now(), + } + if err := h.loadTemplates(); err != nil { + logger.Errorf("failed to load embedded templates: %v", err) + } + return h +} + +// SetCertStatus sets the certificate status provider for ACME prefetch observability. +func (h *Handler) SetCertStatus(cs certStatus) { + h.certStatus = cs +} + +func (h *Handler) loadTemplates() error { + tmpl, err := template.ParseFS(templateFS, "templates/*.html") + if err != nil { + return fmt.Errorf("parse embedded templates: %w", err) + } + + h.templateMu.Lock() + h.templates = tmpl + h.templateMu.Unlock() + + return nil +} + +func (h *Handler) getTemplates() *template.Template { + h.templateMu.RLock() + defer h.templateMu.RUnlock() + return h.templates +} + +// ServeHTTP handles debug requests. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + wantJSON := r.URL.Query().Get("format") == "json" || strings.HasSuffix(path, "/json") + path = strings.TrimSuffix(path, "/json") + + switch path { + case "/debug", "/debug/": + h.handleIndex(w, r, wantJSON) + case "/debug/clients": + h.handleListClients(w, r, wantJSON) + case "/debug/health": + h.handleHealth(w, r, wantJSON) + default: + if h.handleClientRoutes(w, r, path, wantJSON) { + return + } + http.NotFound(w, r) + } +} + +func (h *Handler) handleClientRoutes(w http.ResponseWriter, r *http.Request, path string, wantJSON bool) bool { + if !strings.HasPrefix(path, "/debug/clients/") { + return false + } + + rest := strings.TrimPrefix(path, "/debug/clients/") + parts := strings.SplitN(rest, "/", 2) + accountID := types.AccountID(parts[0]) + + if len(parts) == 1 { + h.handleClientStatus(w, r, accountID, wantJSON) + return true + } + + switch parts[1] { + case "syncresponse": + h.handleClientSyncResponse(w, r, accountID, wantJSON) + case "tools": + h.handleClientTools(w, r, accountID) + case "pingtcp": + h.handlePingTCP(w, r, accountID) + case "loglevel": + h.handleLogLevel(w, r, accountID) + case "start": + h.handleClientStart(w, r, accountID) + case "stop": + h.handleClientStop(w, r, accountID) + default: + return false + } + return true +} + +type failedDomain struct { + Domain string + Error string +} + +type indexData struct { + Version string + Uptime string + ClientCount int + TotalServices int + CertsTotal int + CertsReady int + CertsPending int + CertsFailed int + CertsPendingDomains []string + CertsReadyDomains []string + CertsFailedDomains []failedDomain + Clients []clientData +} + +type clientData struct { + AccountID string + Services string + Age string + Status string +} + +func (h *Handler) handleIndex(w http.ResponseWriter, _ *http.Request, wantJSON bool) { + clients := h.provider.ListClientsForDebug() + sortedIDs := sortedAccountIDs(clients) + + totalServices := 0 + for _, info := range clients { + totalServices += info.ServiceCount + } + + var certsTotal, certsReady, certsPending, certsFailed int + var certsPendingDomains, certsReadyDomains []string + var certsFailedDomains map[string]string + if h.certStatus != nil { + certsTotal = h.certStatus.TotalDomains() + certsPendingDomains = h.certStatus.PendingDomains() + certsReadyDomains = h.certStatus.ReadyDomains() + certsFailedDomains = h.certStatus.FailedDomains() + certsReady = len(certsReadyDomains) + certsPending = len(certsPendingDomains) + certsFailed = len(certsFailedDomains) + } + + if wantJSON { + clientsJSON := make([]map[string]interface{}, 0, len(clients)) + for _, id := range sortedIDs { + info := clients[id] + clientsJSON = append(clientsJSON, map[string]interface{}{ + "account_id": info.AccountID, + "service_count": info.ServiceCount, + "service_keys": info.ServiceKeys, + "has_client": info.HasClient, + "created_at": info.CreatedAt, + "age": time.Since(info.CreatedAt).Round(time.Second).String(), + }) + } + resp := map[string]interface{}{ + "version": version.NetbirdVersion(), + "uptime": time.Since(h.startTime).Round(time.Second).String(), + "client_count": len(clients), + "total_services": totalServices, + "certs_total": certsTotal, + "certs_ready": certsReady, + "certs_pending": certsPending, + "certs_failed": certsFailed, + "clients": clientsJSON, + } + if len(certsPendingDomains) > 0 { + resp["certs_pending_domains"] = certsPendingDomains + } + if len(certsReadyDomains) > 0 { + resp["certs_ready_domains"] = certsReadyDomains + } + if len(certsFailedDomains) > 0 { + resp["certs_failed_domains"] = certsFailedDomains + } + h.writeJSON(w, resp) + return + } + + sortedFailed := make([]failedDomain, 0, len(certsFailedDomains)) + for d, e := range certsFailedDomains { + sortedFailed = append(sortedFailed, failedDomain{Domain: d, Error: e}) + } + slices.SortFunc(sortedFailed, func(a, b failedDomain) int { + return cmp.Compare(a.Domain, b.Domain) + }) + + data := indexData{ + Version: version.NetbirdVersion(), + Uptime: time.Since(h.startTime).Round(time.Second).String(), + ClientCount: len(clients), + TotalServices: totalServices, + CertsTotal: certsTotal, + CertsReady: certsReady, + CertsPending: certsPending, + CertsFailed: certsFailed, + CertsPendingDomains: certsPendingDomains, + CertsReadyDomains: certsReadyDomains, + CertsFailedDomains: sortedFailed, + Clients: make([]clientData, 0, len(clients)), + } + + for _, id := range sortedIDs { + info := clients[id] + services := strings.Join(info.ServiceKeys, ", ") + if services == "" { + services = "-" + } + status := "No client" + if info.HasClient { + status = "Active" + } + data.Clients = append(data.Clients, clientData{ + AccountID: string(info.AccountID), + Services: services, + Age: time.Since(info.CreatedAt).Round(time.Second).String(), + Status: status, + }) + } + + h.renderTemplate(w, "index", data) +} + +type clientsData struct { + Uptime string + Clients []clientData +} + +func (h *Handler) handleListClients(w http.ResponseWriter, _ *http.Request, wantJSON bool) { + clients := h.provider.ListClientsForDebug() + sortedIDs := sortedAccountIDs(clients) + + if wantJSON { + clientsJSON := make([]map[string]interface{}, 0, len(clients)) + for _, id := range sortedIDs { + info := clients[id] + clientsJSON = append(clientsJSON, map[string]interface{}{ + "account_id": info.AccountID, + "service_count": info.ServiceCount, + "service_keys": info.ServiceKeys, + "has_client": info.HasClient, + "created_at": info.CreatedAt, + "age": time.Since(info.CreatedAt).Round(time.Second).String(), + }) + } + h.writeJSON(w, map[string]interface{}{ + "uptime": time.Since(h.startTime).Round(time.Second).String(), + "client_count": len(clients), + "clients": clientsJSON, + }) + return + } + + data := clientsData{ + Uptime: time.Since(h.startTime).Round(time.Second).String(), + Clients: make([]clientData, 0, len(clients)), + } + + for _, id := range sortedIDs { + info := clients[id] + services := strings.Join(info.ServiceKeys, ", ") + if services == "" { + services = "-" + } + status := "No client" + if info.HasClient { + status = "Active" + } + data.Clients = append(data.Clients, clientData{ + AccountID: string(info.AccountID), + Services: services, + Age: time.Since(info.CreatedAt).Round(time.Second).String(), + Status: status, + }) + } + + h.renderTemplate(w, "clients", data) +} + +type clientDetailData struct { + AccountID string + ActiveTab string + Content string +} + +func (h *Handler) handleClientStatus(w http.ResponseWriter, r *http.Request, accountID types.AccountID, wantJSON bool) { + client, ok := h.provider.GetClient(accountID) + if !ok { + http.Error(w, "Client not found: "+string(accountID), http.StatusNotFound) + return + } + + fullStatus, err := client.Status() + if err != nil { + http.Error(w, "Error getting status: "+err.Error(), http.StatusInternalServerError) + return + } + + // Parse filter parameters + query := r.URL.Query() + statusFilter := query.Get("filter-by-status") + connectionTypeFilter := query.Get("filter-by-connection-type") + + var prefixNamesFilter []string + var prefixNamesFilterMap map[string]struct{} + if names := query.Get("filter-by-names"); names != "" { + prefixNamesFilter = strings.Split(names, ",") + prefixNamesFilterMap = make(map[string]struct{}) + for _, name := range prefixNamesFilter { + prefixNamesFilterMap[strings.ToLower(strings.TrimSpace(name))] = struct{}{} + } + } + + var ipsFilterMap map[string]struct{} + if ips := query.Get("filter-by-ips"); ips != "" { + ipsFilterMap = make(map[string]struct{}) + for _, ip := range strings.Split(ips, ",") { + ipsFilterMap[strings.TrimSpace(ip)] = struct{}{} + } + } + + pbStatus := nbstatus.ToProtoFullStatus(fullStatus) + overview := nbstatus.ConvertToStatusOutputOverview( + pbStatus, + false, + version.NetbirdVersion(), + statusFilter, + prefixNamesFilter, + prefixNamesFilterMap, + ipsFilterMap, + connectionTypeFilter, + "", + ) + + if wantJSON { + h.writeJSON(w, map[string]interface{}{ + "account_id": accountID, + "status": overview.FullDetailSummary(), + }) + return + } + + data := clientDetailData{ + AccountID: string(accountID), + ActiveTab: "status", + Content: overview.FullDetailSummary(), + } + + h.renderTemplate(w, "clientDetail", data) +} + +func (h *Handler) handleClientSyncResponse(w http.ResponseWriter, _ *http.Request, accountID types.AccountID, wantJSON bool) { + client, ok := h.provider.GetClient(accountID) + if !ok { + http.Error(w, "Client not found: "+string(accountID), http.StatusNotFound) + return + } + + syncResp, err := client.GetLatestSyncResponse() + if err != nil { + http.Error(w, "Error getting sync response: "+err.Error(), http.StatusInternalServerError) + return + } + + if syncResp == nil { + http.Error(w, "No sync response available for client: "+string(accountID), http.StatusNotFound) + return + } + + opts := protojson.MarshalOptions{ + EmitUnpopulated: true, + UseProtoNames: true, + Indent: " ", + AllowPartial: true, + } + + jsonBytes, err := opts.Marshal(syncResp) + if err != nil { + http.Error(w, "Error marshaling sync response: "+err.Error(), http.StatusInternalServerError) + return + } + + if wantJSON { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(jsonBytes) + return + } + + data := clientDetailData{ + AccountID: string(accountID), + ActiveTab: "syncresponse", + Content: string(jsonBytes), + } + + h.renderTemplate(w, "clientDetail", data) +} + +type toolsData struct { + AccountID string +} + +func (h *Handler) handleClientTools(w http.ResponseWriter, _ *http.Request, accountID types.AccountID) { + _, ok := h.provider.GetClient(accountID) + if !ok { + http.Error(w, "Client not found: "+string(accountID), http.StatusNotFound) + return + } + + data := toolsData{ + AccountID: string(accountID), + } + + h.renderTemplate(w, "tools", data) +} + +func (h *Handler) handlePingTCP(w http.ResponseWriter, r *http.Request, accountID types.AccountID) { + client, ok := h.provider.GetClient(accountID) + if !ok { + h.writeJSON(w, map[string]interface{}{"error": "client not found"}) + return + } + + host := r.URL.Query().Get("host") + portStr := r.URL.Query().Get("port") + if host == "" || portStr == "" { + h.writeJSON(w, map[string]interface{}{"error": "host and port parameters required"}) + return + } + + port, err := strconv.Atoi(portStr) + if err != nil || port < 1 || port > 65535 { + h.writeJSON(w, map[string]interface{}{"error": "invalid port"}) + return + } + + timeout := defaultPingTimeout + if t := r.URL.Query().Get("timeout"); t != "" { + if d, err := time.ParseDuration(t); err == nil { + timeout = d + } + } + + ctx, cancel := context.WithTimeout(r.Context(), timeout) + defer cancel() + + address := fmt.Sprintf("%s:%d", host, port) + start := time.Now() + + conn, err := client.Dial(ctx, "tcp", address) + if err != nil { + h.writeJSON(w, map[string]interface{}{ + "success": false, + "host": host, + "port": port, + "error": err.Error(), + }) + return + } + if err := conn.Close(); err != nil { + h.logger.Debugf("close tcp ping connection: %v", err) + } + + latency := time.Since(start) + h.writeJSON(w, map[string]interface{}{ + "success": true, + "host": host, + "port": port, + "latency_ms": latency.Milliseconds(), + "latency": formatDuration(latency), + }) +} + +func (h *Handler) handleLogLevel(w http.ResponseWriter, r *http.Request, accountID types.AccountID) { + client, ok := h.provider.GetClient(accountID) + if !ok { + h.writeJSON(w, map[string]interface{}{"error": "client not found"}) + return + } + + level := r.URL.Query().Get("level") + if level == "" { + h.writeJSON(w, map[string]interface{}{"error": "level parameter required (trace, debug, info, warn, error)"}) + return + } + + if err := client.SetLogLevel(level); err != nil { + h.writeJSON(w, map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + return + } + + h.writeJSON(w, map[string]interface{}{ + "success": true, + "level": level, + }) +} + +const clientActionTimeout = 30 * time.Second + +func (h *Handler) handleClientStart(w http.ResponseWriter, r *http.Request, accountID types.AccountID) { + client, ok := h.provider.GetClient(accountID) + if !ok { + h.writeJSON(w, map[string]interface{}{"error": "client not found"}) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), clientActionTimeout) + defer cancel() + + if err := client.Start(ctx); err != nil { + h.writeJSON(w, map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + return + } + + h.writeJSON(w, map[string]interface{}{ + "success": true, + "message": "client started", + }) +} + +func (h *Handler) handleClientStop(w http.ResponseWriter, r *http.Request, accountID types.AccountID) { + client, ok := h.provider.GetClient(accountID) + if !ok { + h.writeJSON(w, map[string]interface{}{"error": "client not found"}) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), clientActionTimeout) + defer cancel() + + if err := client.Stop(ctx); err != nil { + h.writeJSON(w, map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + return + } + + h.writeJSON(w, map[string]interface{}{ + "success": true, + "message": "client stopped", + }) +} + +func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request, wantJSON bool) { + if !wantJSON { + http.Redirect(w, r, "/debug", http.StatusSeeOther) + return + } + + uptime := time.Since(h.startTime).Round(10 * time.Millisecond).String() + + ready := h.health.ReadinessProbe() + allHealthy, clientHealth := h.health.CheckClientsConnected(r.Context()) + + status := "ok" + // No clients is not a health issue; only degrade when actual clients are unhealthy + if !ready || (!allHealthy && len(clientHealth) > 0) { + status = "degraded" + } + + var certsTotal, certsReady, certsPending, certsFailed int + var certsPendingDomains, certsReadyDomains []string + var certsFailedDomains map[string]string + if h.certStatus != nil { + certsTotal = h.certStatus.TotalDomains() + certsPendingDomains = h.certStatus.PendingDomains() + certsReadyDomains = h.certStatus.ReadyDomains() + certsFailedDomains = h.certStatus.FailedDomains() + certsReady = len(certsReadyDomains) + certsPending = len(certsPendingDomains) + certsFailed = len(certsFailedDomains) + } + + resp := map[string]any{ + "status": status, + "uptime": uptime, + "management_connected": ready, + "all_clients_healthy": allHealthy, + "certs_total": certsTotal, + "certs_ready": certsReady, + "certs_pending": certsPending, + "certs_failed": certsFailed, + "clients": clientHealth, + } + if len(certsPendingDomains) > 0 { + resp["certs_pending_domains"] = certsPendingDomains + } + if len(certsReadyDomains) > 0 { + resp["certs_ready_domains"] = certsReadyDomains + } + if len(certsFailedDomains) > 0 { + resp["certs_failed_domains"] = certsFailedDomains + } + h.writeJSON(w, resp) +} + +func (h *Handler) renderTemplate(w http.ResponseWriter, name string, data interface{}) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + tmpl := h.getTemplates() + if tmpl == nil { + http.Error(w, "Templates not loaded", http.StatusInternalServerError) + return + } + if err := tmpl.ExecuteTemplate(w, name, data); err != nil { + h.logger.Errorf("execute template %s: %v", name, err) + http.Error(w, "Template error", http.StatusInternalServerError) + } +} + +func (h *Handler) writeJSON(w http.ResponseWriter, v interface{}) { + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(v); err != nil { + h.logger.Errorf("encode JSON response: %v", err) + } +} diff --git a/proxy/internal/debug/templates/base.html b/proxy/internal/debug/templates/base.html new file mode 100644 index 000000000..737bd5b85 --- /dev/null +++ b/proxy/internal/debug/templates/base.html @@ -0,0 +1,101 @@ +{{define "style"}} +body { + font-family: monospace; + margin: 20px; + background: #1a1a1a; + color: #eee; +} +a { + color: #6cf; +} +h1, h2, h3 { + color: #fff; +} +.info { + color: #aaa; +} +table { + border-collapse: collapse; + margin: 10px 0; +} +th, td { + border: 1px solid #444; + padding: 8px; + text-align: left; +} +th { + background: #333; +} +.nav { + margin-bottom: 20px; +} +.nav a { + margin-right: 15px; + padding: 8px 16px; + background: #333; + text-decoration: none; + border-radius: 4px; +} +.nav a.active { + background: #6cf; + color: #000; +} +pre { + background: #222; + padding: 15px; + border-radius: 4px; + overflow-x: auto; + white-space: pre-wrap; +} +input, select, textarea { + background: #333; + color: #eee; + border: 1px solid #555; + padding: 8px; + border-radius: 4px; + font-family: monospace; +} +input:focus, select:focus, textarea:focus { + outline: none; + border-color: #6cf; +} +button { + background: #6cf; + color: #000; + border: none; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + font-family: monospace; +} +button:hover { + background: #5be; +} +button:disabled { + background: #555; + color: #888; + cursor: not-allowed; +} +.form-group { + margin-bottom: 15px; +} +.form-group label { + display: block; + margin-bottom: 5px; + color: #aaa; +} +.form-row { + display: flex; + gap: 10px; + align-items: flex-end; +} +.result { + margin-top: 20px; +} +.success { + color: #5f5; +} +.error { + color: #f55; +} +{{end}} diff --git a/proxy/internal/debug/templates/client_detail.html b/proxy/internal/debug/templates/client_detail.html new file mode 100644 index 000000000..8eb27b1e5 --- /dev/null +++ b/proxy/internal/debug/templates/client_detail.html @@ -0,0 +1,19 @@ +{{define "clientDetail"}} + + + + Client {{.AccountID}} + + + +

Client: {{.AccountID}}

+ +
{{.Content}}
+ + +{{end}} diff --git a/proxy/internal/debug/templates/clients.html b/proxy/internal/debug/templates/clients.html new file mode 100644 index 000000000..bfc25f95a --- /dev/null +++ b/proxy/internal/debug/templates/clients.html @@ -0,0 +1,33 @@ +{{define "clients"}} + + + + Clients + + + +

All Clients

+

Uptime: {{.Uptime}} | ← Back

+ {{if .Clients}} + + + + + + + + {{range .Clients}} + + + + + + + {{end}} +
Account IDServicesAgeStatus
{{.AccountID}}{{.Services}}{{.Age}}{{.Status}}
+ {{else}} +

No clients connected

+ {{end}} + + +{{end}} diff --git a/proxy/internal/debug/templates/index.html b/proxy/internal/debug/templates/index.html new file mode 100644 index 000000000..5bd25adfc --- /dev/null +++ b/proxy/internal/debug/templates/index.html @@ -0,0 +1,58 @@ +{{define "index"}} + + + + NetBird Proxy Debug + + + +

NetBird Proxy Debug

+

Version: {{.Version}} | Uptime: {{.Uptime}}

+

Certificates: {{.CertsReady}} ready, {{.CertsPending}} pending, {{.CertsFailed}} failed ({{.CertsTotal}} total)

+ {{if .CertsReadyDomains}} +
+ Ready domains ({{.CertsReady}}) +
    {{range .CertsReadyDomains}}
  • {{.}}
  • {{end}}
+
+ {{end}} + {{if .CertsPendingDomains}} +
+ Pending domains ({{.CertsPending}}) +
    {{range .CertsPendingDomains}}
  • {{.}}
  • {{end}}
+
+ {{end}} + {{if .CertsFailedDomains}} +
+ Failed domains ({{.CertsFailed}}) +
    {{range .CertsFailedDomains}}
  • {{.Domain}}: {{.Error}}
  • {{end}}
+
+ {{end}} +

Clients ({{.ClientCount}}) | Services ({{.TotalServices}})

+ {{if .Clients}} + + + + + + + + {{range .Clients}} + + + + + + + {{end}} +
Account IDServicesAgeStatus
{{.AccountID}}{{.Services}}{{.Age}}{{.Status}}
+ {{else}} +

No clients connected

+ {{end}} +

Endpoints

+ +

Add ?format=json or /json suffix for JSON output

+ + +{{end}} diff --git a/proxy/internal/debug/templates/tools.html b/proxy/internal/debug/templates/tools.html new file mode 100644 index 000000000..216a44693 --- /dev/null +++ b/proxy/internal/debug/templates/tools.html @@ -0,0 +1,142 @@ +{{define "tools"}} + + + + Client {{.AccountID}} - Tools + + + +

Client: {{.AccountID}}

+ + +

Client Control

+
+
+   + +
+
+   + +
+
+
+ +

Log Level

+
+
+ + +
+
+   + +
+
+
+ +

TCP Ping

+
+
+ + +
+
+ + +
+
+   + +
+
+
+ + + + +{{end}} diff --git a/proxy/internal/flock/flock_other.go b/proxy/internal/flock/flock_other.go new file mode 100644 index 000000000..a3916a442 --- /dev/null +++ b/proxy/internal/flock/flock_other.go @@ -0,0 +1,20 @@ +//go:build !unix + +package flock + +import ( + "context" + "os" +) + +// Lock is a no-op on non-Unix platforms. Returns (nil, nil) to indicate +// that no lock was acquired; callers must treat a nil file as "proceed +// without lock" rather than "lock held by someone else." +func Lock(_ context.Context, _ string) (*os.File, error) { + return nil, nil //nolint:nilnil // intentional: nil file signals locking unsupported on this platform +} + +// Unlock is a no-op on non-Unix platforms. +func Unlock(_ *os.File) error { + return nil +} diff --git a/proxy/internal/flock/flock_test.go b/proxy/internal/flock/flock_test.go new file mode 100644 index 000000000..501a173f7 --- /dev/null +++ b/proxy/internal/flock/flock_test.go @@ -0,0 +1,79 @@ +//go:build unix + +package flock + +import ( + "context" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLockUnlock(t *testing.T) { + lockPath := filepath.Join(t.TempDir(), "test.lock") + + f, err := Lock(context.Background(), lockPath) + require.NoError(t, err) + require.NotNil(t, f) + + _, err = os.Stat(lockPath) + assert.NoError(t, err, "lock file should exist") + + err = Unlock(f) + assert.NoError(t, err) +} + +func TestUnlockNil(t *testing.T) { + err := Unlock(nil) + assert.NoError(t, err, "unlocking nil should be a no-op") +} + +func TestLockRespectsContext(t *testing.T) { + lockPath := filepath.Join(t.TempDir(), "test.lock") + + f1, err := Lock(context.Background(), lockPath) + require.NoError(t, err) + defer func() { require.NoError(t, Unlock(f1)) }() + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + _, err = Lock(ctx, lockPath) + assert.ErrorIs(t, err, context.DeadlineExceeded) +} + +func TestLockBlocks(t *testing.T) { + lockPath := filepath.Join(t.TempDir(), "test.lock") + + f1, err := Lock(context.Background(), lockPath) + require.NoError(t, err) + + var wg sync.WaitGroup + wg.Add(1) + + start := time.Now() + var elapsed time.Duration + + go func() { + defer wg.Done() + f2, err := Lock(context.Background(), lockPath) + elapsed = time.Since(start) + assert.NoError(t, err) + if f2 != nil { + assert.NoError(t, Unlock(f2)) + } + }() + + // Hold the lock for 200ms, then release. + time.Sleep(200 * time.Millisecond) + require.NoError(t, Unlock(f1)) + + wg.Wait() + assert.GreaterOrEqual(t, elapsed, 150*time.Millisecond, + "Lock should have blocked for at least ~200ms") +} diff --git a/proxy/internal/flock/flock_unix.go b/proxy/internal/flock/flock_unix.go new file mode 100644 index 000000000..738859a6f --- /dev/null +++ b/proxy/internal/flock/flock_unix.go @@ -0,0 +1,77 @@ +//go:build unix + +// Package flock provides best-effort advisory file locking using flock(2). +// +// This is used for cross-replica coordination (e.g. preventing duplicate +// ACME requests). Note that flock(2) does NOT work reliably on NFS volumes: +// on NFSv3 it depends on the NLM daemon, on NFSv4 Linux emulates it via +// fcntl locks with different semantics. Callers must treat lock failures +// as non-fatal and proceed without the lock. +package flock + +import ( + "context" + "errors" + "fmt" + "os" + "syscall" + "time" + + log "github.com/sirupsen/logrus" +) + +const retryInterval = 100 * time.Millisecond + +// Lock acquires an exclusive advisory lock on the given file path. +// It creates the lock file if it does not exist. The lock attempt +// respects context cancellation by using non-blocking flock with polling. +// The caller must call Unlock with the returned *os.File when done. +func Lock(ctx context.Context, path string) (*os.File, error) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, fmt.Errorf("open lock file %s: %w", path, err) + } + + timer := time.NewTimer(retryInterval) + defer timer.Stop() + + for { + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err == nil { + return f, nil + } else if !errors.Is(err, syscall.EWOULDBLOCK) { + if cerr := f.Close(); cerr != nil { + log.Debugf("close lock file %s: %v", path, cerr) + } + return nil, fmt.Errorf("acquire lock on %s: %w", path, err) + } + + select { + case <-ctx.Done(): + if cerr := f.Close(); cerr != nil { + log.Debugf("close lock file %s: %v", path, cerr) + } + return nil, ctx.Err() + case <-timer.C: + timer.Reset(retryInterval) + } + } +} + +// Unlock releases the lock and closes the file. +func Unlock(f *os.File) error { + if f == nil { + return nil + } + + defer func() { + if cerr := f.Close(); cerr != nil { + log.Debugf("close lock file: %v", cerr) + } + }() + + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_UN); err != nil { + return fmt.Errorf("release lock: %w", err) + } + + return nil +} diff --git a/proxy/internal/geolocation/download.go b/proxy/internal/geolocation/download.go new file mode 100644 index 000000000..64d515275 --- /dev/null +++ b/proxy/internal/geolocation/download.go @@ -0,0 +1,264 @@ +package geolocation + +import ( + "archive/tar" + "bufio" + "compress/gzip" + "crypto/sha256" + "errors" + "fmt" + "io" + "mime" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + log "github.com/sirupsen/logrus" +) + +const ( + mmdbTarGZURL = "https://pkgs.netbird.io/geolocation-dbs/GeoLite2-City/download?suffix=tar.gz" + mmdbSha256URL = "https://pkgs.netbird.io/geolocation-dbs/GeoLite2-City/download?suffix=tar.gz.sha256" + mmdbInnerName = "GeoLite2-City.mmdb" + + downloadTimeout = 2 * time.Minute + maxMMDBSize = 256 << 20 // 256 MB +) + +// ensureMMDB checks for an existing MMDB file in dataDir. If none is found, +// it downloads from pkgs.netbird.io with SHA256 verification. +func ensureMMDB(logger *log.Logger, dataDir string) (string, error) { + if err := os.MkdirAll(dataDir, 0o755); err != nil { + return "", fmt.Errorf("create geo data directory %s: %w", dataDir, err) + } + + pattern := filepath.Join(dataDir, mmdbGlob) + if files, _ := filepath.Glob(pattern); len(files) > 0 { + mmdbPath := files[len(files)-1] + logger.Debugf("using existing geolocation database: %s", mmdbPath) + return mmdbPath, nil + } + + logger.Info("geolocation database not found, downloading from pkgs.netbird.io") + return downloadMMDB(logger, dataDir) +} + +func downloadMMDB(logger *log.Logger, dataDir string) (string, error) { + client := &http.Client{Timeout: downloadTimeout} + + datedName, err := fetchRemoteFilename(client, mmdbTarGZURL) + if err != nil { + return "", fmt.Errorf("get remote filename: %w", err) + } + + mmdbFilename := deriveMMDBFilename(datedName) + mmdbPath := filepath.Join(dataDir, mmdbFilename) + + tmp, err := os.MkdirTemp("", "geolite-proxy-*") + if err != nil { + return "", fmt.Errorf("create temp directory: %w", err) + } + defer os.RemoveAll(tmp) + + checksumFile := filepath.Join(tmp, "checksum.sha256") + if err := downloadToFile(client, mmdbSha256URL, checksumFile); err != nil { + return "", fmt.Errorf("download checksum: %w", err) + } + + expectedHash, err := readChecksumFile(checksumFile) + if err != nil { + return "", fmt.Errorf("read checksum: %w", err) + } + + tarFile := filepath.Join(tmp, datedName) + logger.Debugf("downloading geolocation database (%s)", datedName) + if err := downloadToFile(client, mmdbTarGZURL, tarFile); err != nil { + return "", fmt.Errorf("download database: %w", err) + } + + if err := verifySHA256(tarFile, expectedHash); err != nil { + return "", fmt.Errorf("verify database checksum: %w", err) + } + + if err := extractMMDBFromTarGZ(tarFile, mmdbPath); err != nil { + return "", fmt.Errorf("extract database: %w", err) + } + + logger.Infof("geolocation database downloaded: %s", mmdbPath) + return mmdbPath, nil +} + +// deriveMMDBFilename converts a tar.gz filename to an MMDB filename. +// Example: GeoLite2-City_20240101.tar.gz -> GeoLite2-City_20240101.mmdb +func deriveMMDBFilename(tarName string) string { + base, _, _ := strings.Cut(tarName, ".") + if !strings.Contains(base, "_") { + return "GeoLite2-City.mmdb" + } + return base + ".mmdb" +} + +func fetchRemoteFilename(client *http.Client, url string) (string, error) { + resp, err := client.Head(url) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("HEAD request: HTTP %d", resp.StatusCode) + } + + cd := resp.Header.Get("Content-Disposition") + if cd == "" { + return "", errors.New("no Content-Disposition header") + } + + _, params, err := mime.ParseMediaType(cd) + if err != nil { + return "", fmt.Errorf("parse Content-Disposition: %w", err) + } + + name := filepath.Base(params["filename"]) + if name == "" || name == "." { + return "", errors.New("no filename in Content-Disposition") + } + return name, nil +} + +func downloadToFile(client *http.Client, url, dest string) error { + resp, err := client.Get(url) //nolint:gosec + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + + f, err := os.Create(dest) //nolint:gosec + if err != nil { + return err + } + defer f.Close() + + // Cap download at 256 MB to prevent unbounded reads from a compromised server. + if _, err := io.Copy(f, io.LimitReader(resp.Body, maxMMDBSize)); err != nil { + return err + } + return nil +} + +func readChecksumFile(path string) (string, error) { + f, err := os.Open(path) //nolint:gosec + if err != nil { + return "", err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + if scanner.Scan() { + parts := strings.Fields(scanner.Text()) + if len(parts) > 0 { + return parts[0], nil + } + } + if err := scanner.Err(); err != nil { + return "", err + } + return "", errors.New("empty checksum file") +} + +func verifySHA256(path, expected string) error { + f, err := os.Open(path) //nolint:gosec + if err != nil { + return err + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return err + } + + actual := fmt.Sprintf("%x", h.Sum(nil)) + if actual != expected { + return fmt.Errorf("SHA256 mismatch: expected %s, got %s", expected, actual) + } + return nil +} + +func extractMMDBFromTarGZ(tarGZPath, destPath string) error { + f, err := os.Open(tarGZPath) //nolint:gosec + if err != nil { + return err + } + defer f.Close() + + gz, err := gzip.NewReader(f) + if err != nil { + return err + } + defer gz.Close() + + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return err + } + + if hdr.Typeflag == tar.TypeReg && filepath.Base(hdr.Name) == mmdbInnerName { + if hdr.Size < 0 || hdr.Size > maxMMDBSize { + return fmt.Errorf("mmdb entry size %d exceeds limit %d", hdr.Size, maxMMDBSize) + } + if err := extractToFileAtomic(io.LimitReader(tr, hdr.Size), destPath); err != nil { + return err + } + return nil + } + } + + return fmt.Errorf("%s not found in archive", mmdbInnerName) +} + +// extractToFileAtomic writes r to a temporary file in the same directory as +// destPath, then renames it into place so a crash never leaves a truncated file. +func extractToFileAtomic(r io.Reader, destPath string) error { + dir := filepath.Dir(destPath) + tmp, err := os.CreateTemp(dir, ".mmdb-*.tmp") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + tmpPath := tmp.Name() + + if _, err := io.Copy(tmp, r); err != nil { //nolint:gosec // G110: caller bounds with LimitReader + if closeErr := tmp.Close(); closeErr != nil { + log.Debugf("failed to close temp file %s: %v", tmpPath, closeErr) + } + if removeErr := os.Remove(tmpPath); removeErr != nil { + log.Debugf("failed to remove temp file %s: %v", tmpPath, removeErr) + } + return fmt.Errorf("write mmdb: %w", err) + } + if err := tmp.Close(); err != nil { + if removeErr := os.Remove(tmpPath); removeErr != nil { + log.Debugf("failed to remove temp file %s: %v", tmpPath, removeErr) + } + return fmt.Errorf("close temp file: %w", err) + } + if err := os.Rename(tmpPath, destPath); err != nil { + if removeErr := os.Remove(tmpPath); removeErr != nil { + log.Debugf("failed to remove temp file %s: %v", tmpPath, removeErr) + } + return fmt.Errorf("rename to %s: %w", destPath, err) + } + return nil +} diff --git a/proxy/internal/geolocation/geolocation.go b/proxy/internal/geolocation/geolocation.go new file mode 100644 index 000000000..81b02efb3 --- /dev/null +++ b/proxy/internal/geolocation/geolocation.go @@ -0,0 +1,152 @@ +// Package geolocation provides IP-to-country lookups using MaxMind GeoLite2 databases. +package geolocation + +import ( + "fmt" + "net/netip" + "os" + "strconv" + "sync" + + "github.com/oschwald/maxminddb-golang" + log "github.com/sirupsen/logrus" +) + +const ( + // EnvDisable disables geolocation lookups entirely when set to a truthy value. + EnvDisable = "NB_PROXY_DISABLE_GEOLOCATION" + + mmdbGlob = "GeoLite2-City_*.mmdb" +) + +type record struct { + Country struct { + ISOCode string `maxminddb:"iso_code"` + } `maxminddb:"country"` + City struct { + Names struct { + En string `maxminddb:"en"` + } `maxminddb:"names"` + } `maxminddb:"city"` + Subdivisions []struct { + ISOCode string `maxminddb:"iso_code"` + Names struct { + En string `maxminddb:"en"` + } `maxminddb:"names"` + } `maxminddb:"subdivisions"` +} + +// Result holds the outcome of a geo lookup. +type Result struct { + CountryCode string + CityName string + SubdivisionCode string + SubdivisionName string +} + +// Lookup provides IP geolocation lookups. +type Lookup struct { + mu sync.RWMutex + db *maxminddb.Reader + logger *log.Logger +} + +// NewLookup opens or downloads the GeoLite2-City MMDB in dataDir. +// Returns nil without error if geolocation is disabled via environment +// variable, no data directory is configured, or the download fails +// (graceful degradation: country restrictions will deny all requests). +func NewLookup(logger *log.Logger, dataDir string) (*Lookup, error) { + if isDisabledByEnv(logger) { + logger.Info("geolocation disabled via environment variable") + return nil, nil //nolint:nilnil + } + + if dataDir == "" { + return nil, nil //nolint:nilnil + } + + mmdbPath, err := ensureMMDB(logger, dataDir) + if err != nil { + logger.Warnf("geolocation database unavailable: %v", err) + logger.Warn("country-based access restrictions will deny all requests until a database is available") + return nil, nil //nolint:nilnil + } + + db, err := maxminddb.Open(mmdbPath) + if err != nil { + return nil, fmt.Errorf("open GeoLite2 database %s: %w", mmdbPath, err) + } + + logger.Infof("geolocation database loaded from %s", mmdbPath) + return &Lookup{db: db, logger: logger}, nil +} + +// LookupAddr returns the country ISO code and city name for the given IP. +// Returns an empty Result if the database is nil or the lookup fails. +func (l *Lookup) LookupAddr(addr netip.Addr) Result { + if l == nil { + return Result{} + } + + l.mu.RLock() + defer l.mu.RUnlock() + + if l.db == nil { + return Result{} + } + + addr = addr.Unmap() + + var rec record + if err := l.db.Lookup(addr.AsSlice(), &rec); err != nil { + l.logger.Debugf("geolocation lookup %s: %v", addr, err) + return Result{} + } + r := Result{ + CountryCode: rec.Country.ISOCode, + CityName: rec.City.Names.En, + } + if len(rec.Subdivisions) > 0 { + r.SubdivisionCode = rec.Subdivisions[0].ISOCode + r.SubdivisionName = rec.Subdivisions[0].Names.En + } + return r +} + +// Available reports whether the lookup has a loaded database. +func (l *Lookup) Available() bool { + if l == nil { + return false + } + l.mu.RLock() + defer l.mu.RUnlock() + return l.db != nil +} + +// Close releases the database resources. +func (l *Lookup) Close() error { + if l == nil { + return nil + } + l.mu.Lock() + defer l.mu.Unlock() + if l.db != nil { + err := l.db.Close() + l.db = nil + return err + } + return nil +} + +func isDisabledByEnv(logger *log.Logger) bool { + val := os.Getenv(EnvDisable) + if val == "" { + return false + } + disabled, err := strconv.ParseBool(val) + if err != nil { + logger.Warnf("parse %s=%q: %v", EnvDisable, val, err) + return false + } + return disabled +} diff --git a/proxy/internal/grpc/auth.go b/proxy/internal/grpc/auth.go new file mode 100644 index 000000000..ce1a23f68 --- /dev/null +++ b/proxy/internal/grpc/auth.go @@ -0,0 +1,48 @@ +// Package grpc provides gRPC utilities for the proxy client. +package grpc + +import ( + "context" + "os" + "strconv" + + log "github.com/sirupsen/logrus" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" +) + +// EnvProxyAllowInsecure controls whether the proxy token can be sent over non-TLS connections. +const EnvProxyAllowInsecure = "NB_PROXY_ALLOW_INSECURE" + +var _ credentials.PerRPCCredentials = (*proxyAuthToken)(nil) + +type proxyAuthToken struct { + token string + allowInsecure bool +} + +func (t proxyAuthToken) GetRequestMetadata(context.Context, ...string) (map[string]string, error) { + return map[string]string{ + "authorization": "Bearer " + t.token, + }, nil +} + +// RequireTransportSecurity returns true by default to protect the token in transit. +// Set NB_PROXY_ALLOW_INSECURE=true to allow non-TLS connections (not recommended for production). +func (t proxyAuthToken) RequireTransportSecurity() bool { + return !t.allowInsecure +} + +// WithProxyToken returns a DialOption that sets the proxy access token on each outbound RPC. +func WithProxyToken(token string) grpc.DialOption { + allowInsecure := false + if val := os.Getenv(EnvProxyAllowInsecure); val != "" { + parsed, err := strconv.ParseBool(val) + if err != nil { + log.Warnf("invalid value for %s: %v", EnvProxyAllowInsecure, err) + } else { + allowInsecure = parsed + } + } + return grpc.WithPerRPCCredentials(proxyAuthToken{token: token, allowInsecure: allowInsecure}) +} diff --git a/proxy/internal/health/health.go b/proxy/internal/health/health.go new file mode 100644 index 000000000..60ce7f8ef --- /dev/null +++ b/proxy/internal/health/health.go @@ -0,0 +1,405 @@ +// Package health provides health probes for the proxy server. +package health + +import ( + "context" + "encoding/json" + "net" + "net/http" + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/embed" + "github.com/netbirdio/netbird/proxy/internal/types" +) + +const handshakeStaleThreshold = 5 * time.Minute + +const ( + maxConcurrentChecks = 3 + maxClientCheckTimeout = 5 * time.Minute +) + +// clientProvider provides access to NetBird clients for health checks. +type clientProvider interface { + ListClientsForStartup() map[types.AccountID]*embed.Client +} + +// Checker tracks health state and provides probe endpoints. +type Checker struct { + logger *log.Logger + provider clientProvider + + mu sync.RWMutex + managementConnected bool + initialSyncComplete bool + shuttingDown bool + + // checkSem limits concurrent client health checks. + checkSem chan struct{} + + // checkHealth checks the health of a single client. + // Defaults to checkClientHealth; overridable in tests. + checkHealth func(*embed.Client) ClientHealth +} + +// ClientHealth represents the health status of a single NetBird client. +type ClientHealth struct { + Healthy bool `json:"healthy"` + ManagementConnected bool `json:"management_connected"` + SignalConnected bool `json:"signal_connected"` + RelaysConnected int `json:"relays_connected"` + RelaysTotal int `json:"relays_total"` + PeersTotal int `json:"peers_total"` + PeersConnected int `json:"peers_connected"` + PeersP2P int `json:"peers_p2p"` + PeersRelayed int `json:"peers_relayed"` + PeersDegraded int `json:"peers_degraded"` + Error string `json:"error,omitempty"` +} + +// ProbeResponse represents the JSON response for health probes. +type ProbeResponse struct { + Status string `json:"status"` + Checks map[string]bool `json:"checks,omitempty"` + Clients map[types.AccountID]ClientHealth `json:"clients,omitempty"` +} + +// Server runs the health probe HTTP server on a dedicated port. +type Server struct { + server *http.Server + logger *log.Logger + checker *Checker +} + +// SetManagementConnected updates the management connection state. +func (c *Checker) SetManagementConnected(connected bool) { + c.mu.Lock() + defer c.mu.Unlock() + c.managementConnected = connected +} + +// SetInitialSyncComplete marks that the initial mapping sync has completed. +func (c *Checker) SetInitialSyncComplete() { + c.mu.Lock() + defer c.mu.Unlock() + c.initialSyncComplete = true +} + +// SetShuttingDown marks the server as shutting down. +// This causes ReadinessProbe to return false so load balancers stop routing traffic. +func (c *Checker) SetShuttingDown() { + c.mu.Lock() + defer c.mu.Unlock() + c.shuttingDown = true +} + +// CheckClientsConnected verifies all clients are connected to management/signal/relay. +// Uses the provided context for timeout/cancellation, with a maximum bound of maxClientCheckTimeout. +// Limits concurrent checks via semaphore. +func (c *Checker) CheckClientsConnected(ctx context.Context) (bool, map[types.AccountID]ClientHealth) { + // Apply upper bound timeout in case parent context has no deadline + ctx, cancel := context.WithTimeout(ctx, maxClientCheckTimeout) + defer cancel() + + clients := c.provider.ListClientsForStartup() + + // No clients is not a health issue + if len(clients) == 0 { + return true, make(map[types.AccountID]ClientHealth) + } + + type result struct { + accountID types.AccountID + health ClientHealth + } + + resultsCh := make(chan result, len(clients)) + var wg sync.WaitGroup + + for accountID, client := range clients { + wg.Add(1) + go func(id types.AccountID, cl *embed.Client) { + defer wg.Done() + + // Acquire semaphore + select { + case c.checkSem <- struct{}{}: + defer func() { <-c.checkSem }() + case <-ctx.Done(): + resultsCh <- result{id, ClientHealth{Healthy: false, Error: ctx.Err().Error()}} + return + } + + resultsCh <- result{id, c.checkHealth(cl)} + }(accountID, client) + } + + go func() { + wg.Wait() + close(resultsCh) + }() + + results := make(map[types.AccountID]ClientHealth) + allHealthy := true + for r := range resultsCh { + results[r.accountID] = r.health + if !r.health.Healthy { + allHealthy = false + } + } + + return allHealthy, results +} + +// LivenessProbe returns true if the process is alive. +// This should always return true if we can respond. +func (c *Checker) LivenessProbe() bool { + return true +} + +// ReadinessProbe returns true if the server can accept traffic. +func (c *Checker) ReadinessProbe() bool { + c.mu.RLock() + defer c.mu.RUnlock() + if c.shuttingDown { + return false + } + return c.managementConnected +} + +// StartupProbe checks if initial startup is complete. +// Checks management connection, initial sync, and all client health directly. +// Uses the provided context for timeout/cancellation. +func (c *Checker) StartupProbe(ctx context.Context) bool { + c.mu.RLock() + mgmt := c.managementConnected + sync := c.initialSyncComplete + c.mu.RUnlock() + + if !mgmt || !sync { + return false + } + + // Check all clients are connected to management/signal/relay. + // Returns true when no clients exist (nothing to check). + allHealthy, _ := c.CheckClientsConnected(ctx) + return allHealthy +} + +// Handler returns an http.Handler for health probe endpoints. +func (c *Checker) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/healthz/live", c.handleLiveness) + mux.HandleFunc("/healthz/ready", c.handleReadiness) + mux.HandleFunc("/healthz/startup", c.handleStartup) + mux.HandleFunc("/healthz", c.handleFull) + return mux +} + +func (c *Checker) handleLiveness(w http.ResponseWriter, r *http.Request) { + if c.LivenessProbe() { + c.writeProbeResponse(w, http.StatusOK, "ok", nil, nil) + return + } + c.writeProbeResponse(w, http.StatusServiceUnavailable, "fail", nil, nil) +} + +func (c *Checker) handleReadiness(w http.ResponseWriter, r *http.Request) { + c.mu.RLock() + checks := map[string]bool{ + "management_connected": c.managementConnected, + } + c.mu.RUnlock() + + if c.ReadinessProbe() { + c.writeProbeResponse(w, http.StatusOK, "ok", checks, nil) + return + } + c.writeProbeResponse(w, http.StatusServiceUnavailable, "fail", checks, nil) +} + +func (c *Checker) handleStartup(w http.ResponseWriter, r *http.Request) { + c.mu.RLock() + mgmt := c.managementConnected + syncComplete := c.initialSyncComplete + c.mu.RUnlock() + + allClientsHealthy, clientHealth := c.CheckClientsConnected(r.Context()) + + checks := map[string]bool{ + "management_connected": mgmt, + "initial_sync_complete": syncComplete, + "all_clients_healthy": allClientsHealthy, + } + + ready := mgmt && syncComplete && allClientsHealthy + if ready { + c.writeProbeResponse(w, http.StatusOK, "ok", checks, clientHealth) + return + } + c.writeProbeResponse(w, http.StatusServiceUnavailable, "fail", checks, clientHealth) +} + +func (c *Checker) handleFull(w http.ResponseWriter, r *http.Request) { + c.mu.RLock() + mgmt := c.managementConnected + sync := c.initialSyncComplete + c.mu.RUnlock() + + allClientsHealthy, clientHealth := c.CheckClientsConnected(r.Context()) + + checks := map[string]bool{ + "management_connected": mgmt, + "initial_sync_complete": sync, + "all_clients_healthy": allClientsHealthy, + } + + status := "ok" + statusCode := http.StatusOK + if !c.ReadinessProbe() { + status = "fail" + statusCode = http.StatusServiceUnavailable + } + + c.writeProbeResponse(w, statusCode, status, checks, clientHealth) +} + +func (c *Checker) writeProbeResponse(w http.ResponseWriter, statusCode int, status string, checks map[string]bool, clients map[types.AccountID]ClientHealth) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + + resp := ProbeResponse{ + Status: status, + Checks: checks, + Clients: clients, + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + c.logger.Debugf("write health response: %v", err) + } +} + +// ListenAndServe starts the health probe server. +func (s *Server) ListenAndServe() error { + s.logger.Infof("starting health probe server on %s", s.server.Addr) + return s.server.ListenAndServe() +} + +// Serve starts the health probe server on the given listener. +func (s *Server) Serve(l net.Listener) error { + s.logger.Infof("starting health probe server on %s", l.Addr()) + return s.server.Serve(l) +} + +// Shutdown gracefully shuts down the health probe server. +func (s *Server) Shutdown(ctx context.Context) error { + return s.server.Shutdown(ctx) +} + +// NewChecker creates a new health checker. +func NewChecker(logger *log.Logger, provider clientProvider) *Checker { + if logger == nil { + logger = log.StandardLogger() + } + return &Checker{ + logger: logger, + provider: provider, + checkSem: make(chan struct{}, maxConcurrentChecks), + checkHealth: checkClientHealth, + } +} + +// NewServer creates a new health probe server. +// If metricsHandler is non-nil, it is mounted at /metrics on the same port. +func NewServer(addr string, checker *Checker, logger *log.Logger, metricsHandler http.Handler) *Server { + if logger == nil { + logger = log.StandardLogger() + } + + handler := checker.Handler() + if metricsHandler != nil { + mux := http.NewServeMux() + mux.Handle("/metrics", metricsHandler) + mux.Handle("/", handler) + handler = mux + } + + return &Server{ + server: &http.Server{ + Addr: addr, + Handler: handler, + ReadTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, + }, + logger: logger, + checker: checker, + } +} + +func checkClientHealth(client *embed.Client) ClientHealth { + if client == nil { + return ClientHealth{ + Healthy: false, + Error: "client not initialized", + } + } + + status, err := client.Status() + if err != nil { + return ClientHealth{ + Healthy: false, + Error: err.Error(), + } + } + + // Count only rel:// and rels:// relays (not stun/turn) + var relayCount, relaysConnected int + for _, relay := range status.Relays { + if !strings.HasPrefix(relay.URI, "rel://") && !strings.HasPrefix(relay.URI, "rels://") { + continue + } + relayCount++ + if relay.Err == nil { + relaysConnected++ + } + } + + // Count peer connection stats + now := time.Now() + var peersConnected, peersP2P, peersRelayed, peersDegraded int + for _, p := range status.Peers { + if p.ConnStatus != embed.PeerStatusConnected { + continue + } + peersConnected++ + if p.Relayed { + peersRelayed++ + } else { + peersP2P++ + } + if p.LastWireguardHandshake.IsZero() || now.Sub(p.LastWireguardHandshake) > handshakeStaleThreshold { + peersDegraded++ + } + } + + // Client is healthy if connected to management, signal, and at least one relay (if any are defined) + healthy := status.ManagementState.Connected && + status.SignalState.Connected && + (relayCount == 0 || relaysConnected > 0) + + return ClientHealth{ + Healthy: healthy, + ManagementConnected: status.ManagementState.Connected, + SignalConnected: status.SignalState.Connected, + RelaysConnected: relaysConnected, + RelaysTotal: relayCount, + PeersTotal: len(status.Peers), + PeersConnected: peersConnected, + PeersP2P: peersP2P, + PeersRelayed: peersRelayed, + PeersDegraded: peersDegraded, + } +} diff --git a/proxy/internal/health/health_test.go b/proxy/internal/health/health_test.go new file mode 100644 index 000000000..47b5f250f --- /dev/null +++ b/proxy/internal/health/health_test.go @@ -0,0 +1,473 @@ +package health + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/embed" + "github.com/netbirdio/netbird/proxy/internal/types" +) + +type mockClientProvider struct { + clients map[types.AccountID]*embed.Client +} + +func (m *mockClientProvider) ListClientsForStartup() map[types.AccountID]*embed.Client { + return m.clients +} + +// newTestChecker creates a checker with a mock health function for testing. +// The health function returns the provided ClientHealth for every client. +func newTestChecker(provider clientProvider, healthResult ClientHealth) *Checker { + c := NewChecker(nil, provider) + c.checkHealth = func(_ *embed.Client) ClientHealth { + return healthResult + } + return c +} + +func TestChecker_LivenessProbe(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + + // Liveness should always return true if we can respond. + assert.True(t, checker.LivenessProbe()) +} + +func TestChecker_ReadinessProbe(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + + // Initially not ready (management not connected). + assert.False(t, checker.ReadinessProbe()) + + // After management connects, should be ready. + checker.SetManagementConnected(true) + assert.True(t, checker.ReadinessProbe()) + + // If management disconnects, should not be ready. + checker.SetManagementConnected(false) + assert.False(t, checker.ReadinessProbe()) +} + +// TestStartupProbe_EmptyServiceList covers the scenario where management has +// no services configured for this proxy. The proxy should become ready once +// management is connected and the initial sync completes, even with zero clients. +func TestStartupProbe_EmptyServiceList(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + + // No management connection = not ready. + assert.False(t, checker.StartupProbe(context.Background())) + + // Management connected but no sync = not ready. + checker.SetManagementConnected(true) + assert.False(t, checker.StartupProbe(context.Background())) + + // Management + sync complete + no clients = ready. + checker.SetInitialSyncComplete() + assert.True(t, checker.StartupProbe(context.Background())) +} + +// TestStartupProbe_WithUnhealthyClients verifies that when services exist +// and clients have been created but are not yet fully connected (to mgmt, +// signal, relays), the startup probe does NOT pass. +func TestStartupProbe_WithUnhealthyClients(t *testing.T) { + provider := &mockClientProvider{ + clients: map[types.AccountID]*embed.Client{ + "account-1": nil, // concrete client not needed; checkHealth is mocked + "account-2": nil, + }, + } + checker := newTestChecker(provider, ClientHealth{Healthy: false, Error: "not connected yet"}) + checker.SetManagementConnected(true) + checker.SetInitialSyncComplete() + + assert.False(t, checker.StartupProbe(context.Background()), + "startup probe must not pass when clients are unhealthy") +} + +// TestStartupProbe_WithHealthyClients verifies that once all clients are +// connected and healthy, the startup probe passes. +func TestStartupProbe_WithHealthyClients(t *testing.T) { + provider := &mockClientProvider{ + clients: map[types.AccountID]*embed.Client{ + "account-1": nil, + "account-2": nil, + }, + } + checker := newTestChecker(provider, ClientHealth{ + Healthy: true, + ManagementConnected: true, + SignalConnected: true, + RelaysConnected: 1, + RelaysTotal: 1, + }) + checker.SetManagementConnected(true) + checker.SetInitialSyncComplete() + + assert.True(t, checker.StartupProbe(context.Background()), + "startup probe must pass when all clients are healthy") +} + +// TestStartupProbe_MixedHealthClients verifies that if any single client is +// unhealthy, the startup probe fails (all-or-nothing). +func TestStartupProbe_MixedHealthClients(t *testing.T) { + provider := &mockClientProvider{ + clients: map[types.AccountID]*embed.Client{ + "healthy-account": nil, + "unhealthy-account": nil, + }, + } + + checker := NewChecker(nil, provider) + checker.checkHealth = func(cl *embed.Client) ClientHealth { + // We identify accounts by their position in the map iteration; since we + // can't control map order, make exactly one unhealthy via counter. + return ClientHealth{Healthy: false} + } + checker.SetManagementConnected(true) + checker.SetInitialSyncComplete() + + assert.False(t, checker.StartupProbe(context.Background()), + "startup probe must fail if any client is unhealthy") +} + +// TestStartupProbe_RequiresAllConditions ensures that each individual +// prerequisite (management, sync, clients) is necessary. The probe must not +// pass if any one is missing. +func TestStartupProbe_RequiresAllConditions(t *testing.T) { + provider := &mockClientProvider{ + clients: map[types.AccountID]*embed.Client{ + "account-1": nil, + }, + } + + t.Run("no management", func(t *testing.T) { + checker := newTestChecker(provider, ClientHealth{Healthy: true}) + checker.SetInitialSyncComplete() + // management NOT connected + assert.False(t, checker.StartupProbe(context.Background())) + }) + + t.Run("no sync", func(t *testing.T) { + checker := newTestChecker(provider, ClientHealth{Healthy: true}) + checker.SetManagementConnected(true) + // sync NOT complete + assert.False(t, checker.StartupProbe(context.Background())) + }) + + t.Run("unhealthy client", func(t *testing.T) { + checker := newTestChecker(provider, ClientHealth{Healthy: false}) + checker.SetManagementConnected(true) + checker.SetInitialSyncComplete() + assert.False(t, checker.StartupProbe(context.Background())) + }) + + t.Run("all conditions met", func(t *testing.T) { + checker := newTestChecker(provider, ClientHealth{Healthy: true}) + checker.SetManagementConnected(true) + checker.SetInitialSyncComplete() + assert.True(t, checker.StartupProbe(context.Background())) + }) +} + +// TestStartupProbe_ConcurrentAccess runs the startup probe from many +// goroutines simultaneously to check for races. +func TestStartupProbe_ConcurrentAccess(t *testing.T) { + provider := &mockClientProvider{ + clients: map[types.AccountID]*embed.Client{ + "account-1": nil, + "account-2": nil, + }, + } + checker := newTestChecker(provider, ClientHealth{Healthy: true}) + checker.SetManagementConnected(true) + checker.SetInitialSyncComplete() + + var wg sync.WaitGroup + const goroutines = 50 + results := make([]bool, goroutines) + + for i := range goroutines { + wg.Add(1) + go func(idx int) { + defer wg.Done() + results[idx] = checker.StartupProbe(context.Background()) + }(i) + } + wg.Wait() + + for i, r := range results { + assert.True(t, r, "goroutine %d got unexpected result", i) + } +} + +// TestStartupProbe_CancelledContext verifies that a cancelled context causes +// the probe to report unhealthy when client checks are needed. +func TestStartupProbe_CancelledContext(t *testing.T) { + t.Run("no management bypasses context", func(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + // Should be false because management isn't connected, context is irrelevant. + assert.False(t, checker.StartupProbe(ctx)) + }) + + t.Run("with clients and cancelled context", func(t *testing.T) { + provider := &mockClientProvider{ + clients: map[types.AccountID]*embed.Client{ + "account-1": nil, + }, + } + checker := NewChecker(nil, provider) + // Use the real checkHealth path — a cancelled context should cause + // the semaphore acquisition to fail, reporting unhealthy. + checker.SetManagementConnected(true) + checker.SetInitialSyncComplete() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + assert.False(t, checker.StartupProbe(ctx), + "cancelled context must result in unhealthy when clients exist") + }) +} + +// TestHandler_Startup_EmptyServiceList verifies the HTTP startup endpoint +// returns 200 when management is connected, sync is complete, and there are +// no services/clients. +func TestHandler_Startup_EmptyServiceList(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + checker.SetManagementConnected(true) + checker.SetInitialSyncComplete() + handler := checker.Handler() + + req := httptest.NewRequest(http.MethodGet, "/healthz/startup", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var resp ProbeResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "ok", resp.Status) + assert.True(t, resp.Checks["management_connected"]) + assert.True(t, resp.Checks["initial_sync_complete"]) + assert.True(t, resp.Checks["all_clients_healthy"]) + assert.Empty(t, resp.Clients) +} + +// TestHandler_Startup_WithUnhealthyClients verifies that the HTTP startup +// endpoint returns 503 when clients exist but are not yet healthy. +func TestHandler_Startup_WithUnhealthyClients(t *testing.T) { + provider := &mockClientProvider{ + clients: map[types.AccountID]*embed.Client{ + "account-1": nil, + }, + } + checker := newTestChecker(provider, ClientHealth{Healthy: false, Error: "starting"}) + checker.SetManagementConnected(true) + checker.SetInitialSyncComplete() + handler := checker.Handler() + + req := httptest.NewRequest(http.MethodGet, "/healthz/startup", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusServiceUnavailable, rec.Code) + + var resp ProbeResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "fail", resp.Status) + assert.True(t, resp.Checks["management_connected"]) + assert.True(t, resp.Checks["initial_sync_complete"]) + assert.False(t, resp.Checks["all_clients_healthy"]) + require.Contains(t, resp.Clients, types.AccountID("account-1")) + assert.Equal(t, "starting", resp.Clients["account-1"].Error) +} + +// TestHandler_Startup_WithHealthyClients verifies the HTTP startup endpoint +// returns 200 once clients are healthy. +func TestHandler_Startup_WithHealthyClients(t *testing.T) { + provider := &mockClientProvider{ + clients: map[types.AccountID]*embed.Client{ + "account-1": nil, + }, + } + checker := newTestChecker(provider, ClientHealth{ + Healthy: true, + ManagementConnected: true, + SignalConnected: true, + RelaysConnected: 1, + RelaysTotal: 1, + }) + checker.SetManagementConnected(true) + checker.SetInitialSyncComplete() + handler := checker.Handler() + + req := httptest.NewRequest(http.MethodGet, "/healthz/startup", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var resp ProbeResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "ok", resp.Status) + assert.True(t, resp.Checks["all_clients_healthy"]) +} + +// TestHandler_Startup_NotComplete verifies the startup handler returns 503 +// when prerequisites aren't met. +func TestHandler_Startup_NotComplete(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + handler := checker.Handler() + + req := httptest.NewRequest(http.MethodGet, "/healthz/startup", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusServiceUnavailable, rec.Code) + + var resp ProbeResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "fail", resp.Status) +} + +func TestChecker_Handler_Liveness(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + handler := checker.Handler() + + req := httptest.NewRequest(http.MethodGet, "/healthz/live", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var resp ProbeResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "ok", resp.Status) +} + +func TestChecker_Handler_Readiness_NotReady(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + handler := checker.Handler() + + req := httptest.NewRequest(http.MethodGet, "/healthz/ready", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusServiceUnavailable, rec.Code) + + var resp ProbeResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "fail", resp.Status) + assert.False(t, resp.Checks["management_connected"]) +} + +func TestChecker_Handler_Readiness_Ready(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + checker.SetManagementConnected(true) + handler := checker.Handler() + + req := httptest.NewRequest(http.MethodGet, "/healthz/ready", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var resp ProbeResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "ok", resp.Status) + assert.True(t, resp.Checks["management_connected"]) +} + +func TestChecker_Handler_Full(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + checker.SetManagementConnected(true) + handler := checker.Handler() + + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var resp ProbeResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "ok", resp.Status) + assert.NotNil(t, resp.Checks) + // Clients may be empty map when no clients exist. + assert.Empty(t, resp.Clients) +} + +func TestChecker_SetShuttingDown(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + checker.SetManagementConnected(true) + + assert.True(t, checker.ReadinessProbe(), "should be ready before shutdown") + + checker.SetShuttingDown() + + assert.False(t, checker.ReadinessProbe(), "should not be ready after shutdown") +} + +func TestChecker_Handler_Readiness_ShuttingDown(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + checker.SetManagementConnected(true) + checker.SetShuttingDown() + handler := checker.Handler() + + req := httptest.NewRequest(http.MethodGet, "/healthz/ready", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusServiceUnavailable, rec.Code) + + var resp ProbeResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "fail", resp.Status) +} + +func TestNewServer_WithMetricsHandler(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + checker.SetManagementConnected(true) + + metricsHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("metrics")) + }) + + srv := NewServer(":0", checker, nil, metricsHandler) + require.NotNil(t, srv) + + // Verify health endpoint still works through the mux. + req := httptest.NewRequest(http.MethodGet, "/healthz/live", nil) + rec := httptest.NewRecorder() + srv.server.Handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) + + // Verify metrics endpoint is mounted. + req = httptest.NewRequest(http.MethodGet, "/metrics", nil) + rec = httptest.NewRecorder() + srv.server.Handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "metrics", rec.Body.String()) +} + +func TestNewServer_WithoutMetricsHandler(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + checker.SetManagementConnected(true) + + srv := NewServer(":0", checker, nil, nil) + require.NotNil(t, srv) + + req := httptest.NewRequest(http.MethodGet, "/healthz/live", nil) + rec := httptest.NewRecorder() + srv.server.Handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) +} diff --git a/proxy/internal/k8s/lease.go b/proxy/internal/k8s/lease.go new file mode 100644 index 000000000..9677e0e27 --- /dev/null +++ b/proxy/internal/k8s/lease.go @@ -0,0 +1,281 @@ +// Package k8s provides a lightweight Kubernetes API client for coordination +// Leases. It uses raw HTTP calls against the mounted service account +// credentials, avoiding a dependency on client-go. +package k8s + +import ( + "bytes" + "context" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +const ( + saTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" //nolint:gosec + saNamespacePath = "/var/run/secrets/kubernetes.io/serviceaccount/namespace" + saCACertPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" + + leaseAPIPath = "/apis/coordination.k8s.io/v1" +) + +// ErrConflict is returned when a Lease update fails due to a +// resourceVersion mismatch (another writer updated the object first). +var ErrConflict = errors.New("conflict: resource version mismatch") + +// Lease represents a coordination.k8s.io/v1 Lease object with only the +// fields needed for distributed locking. +type Lease struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Metadata LeaseMetadata `json:"metadata"` + Spec LeaseSpec `json:"spec"` +} + +// LeaseMetadata holds the standard k8s object metadata fields used by Leases. +type LeaseMetadata struct { + Name string `json:"name"` + Namespace string `json:"namespace,omitempty"` + ResourceVersion string `json:"resourceVersion,omitempty"` + Annotations map[string]string `json:"annotations,omitempty"` +} + +// LeaseSpec holds the Lease specification fields. +type LeaseSpec struct { + HolderIdentity *string `json:"holderIdentity"` + LeaseDurationSeconds *int32 `json:"leaseDurationSeconds,omitempty"` + AcquireTime *MicroTime `json:"acquireTime"` + RenewTime *MicroTime `json:"renewTime"` +} + +// MicroTime wraps time.Time with Kubernetes MicroTime JSON formatting. +type MicroTime struct { + time.Time +} + +const microTimeFormat = "2006-01-02T15:04:05.000000Z" + +// MarshalJSON implements json.Marshaler with k8s MicroTime format. +func (t *MicroTime) MarshalJSON() ([]byte, error) { + return json.Marshal(t.UTC().Format(microTimeFormat)) +} + +// UnmarshalJSON implements json.Unmarshaler with k8s MicroTime format. +func (t *MicroTime) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + if s == "" { + t.Time = time.Time{} + return nil + } + + parsed, err := time.Parse(microTimeFormat, s) + if err != nil { + return fmt.Errorf("parse MicroTime %q: %w", s, err) + } + t.Time = parsed + return nil +} + +// LeaseClient talks to the Kubernetes coordination API using raw HTTP. +type LeaseClient struct { + baseURL string + namespace string + httpClient *http.Client +} + +// NewLeaseClient creates a client that authenticates via the pod's +// mounted service account. It reads the namespace and CA certificate +// at construction time (they don't rotate) but reads the bearer token +// fresh on each request (tokens rotate). +func NewLeaseClient() (*LeaseClient, error) { + host := os.Getenv("KUBERNETES_SERVICE_HOST") + port := os.Getenv("KUBERNETES_SERVICE_PORT") + if host == "" || port == "" { + return nil, fmt.Errorf("KUBERNETES_SERVICE_HOST/PORT not set") + } + + ns, err := os.ReadFile(saNamespacePath) + if err != nil { + return nil, fmt.Errorf("read namespace from %s: %w", saNamespacePath, err) + } + + caCert, err := os.ReadFile(saCACertPath) + if err != nil { + return nil, fmt.Errorf("read CA cert from %s: %w", saCACertPath, err) + } + + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(caCert) { + return nil, fmt.Errorf("parse CA certificate from %s", saCACertPath) + } + + return &LeaseClient{ + baseURL: fmt.Sprintf("https://%s:%s", host, port), + namespace: strings.TrimSpace(string(ns)), + httpClient: &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: pool, + }, + }, + }, + }, nil +} + +// Namespace returns the namespace this client operates in. +func (c *LeaseClient) Namespace() string { + return c.namespace +} + +// Get retrieves a Lease by name. Returns (nil, nil) if the Lease does not exist. +func (c *LeaseClient) Get(ctx context.Context, name string) (*Lease, error) { + url := fmt.Sprintf("%s%s/namespaces/%s/leases/%s", c.baseURL, leaseAPIPath, c.namespace, name) + + resp, err := c.doRequest(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusNotFound { + return nil, nil //nolint:nilnil + } + if resp.StatusCode != http.StatusOK { + return nil, c.readError(resp) + } + + var lease Lease + if err := json.NewDecoder(resp.Body).Decode(&lease); err != nil { + return nil, fmt.Errorf("decode lease response: %w", err) + } + return &lease, nil +} + +// Create creates a new Lease. Returns the created Lease with server-assigned +// fields like resourceVersion populated. +func (c *LeaseClient) Create(ctx context.Context, lease *Lease) (*Lease, error) { + url := fmt.Sprintf("%s%s/namespaces/%s/leases", c.baseURL, leaseAPIPath, c.namespace) + + lease.APIVersion = "coordination.k8s.io/v1" + lease.Kind = "Lease" + if lease.Metadata.Namespace == "" { + lease.Metadata.Namespace = c.namespace + } + + resp, err := c.doRequest(ctx, http.MethodPost, url, lease) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusConflict { + return nil, ErrConflict + } + if resp.StatusCode != http.StatusCreated { + return nil, c.readError(resp) + } + + var created Lease + if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { + return nil, fmt.Errorf("decode created lease: %w", err) + } + return &created, nil +} + +// Update replaces a Lease. The lease.Metadata.ResourceVersion must match +// the current server value (optimistic concurrency). Returns ErrConflict +// on version mismatch. +func (c *LeaseClient) Update(ctx context.Context, lease *Lease) (*Lease, error) { + url := fmt.Sprintf("%s%s/namespaces/%s/leases/%s", c.baseURL, leaseAPIPath, c.namespace, lease.Metadata.Name) + + lease.APIVersion = "coordination.k8s.io/v1" + lease.Kind = "Lease" + + resp, err := c.doRequest(ctx, http.MethodPut, url, lease) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusConflict { + return nil, ErrConflict + } + if resp.StatusCode != http.StatusOK { + return nil, c.readError(resp) + } + + var updated Lease + if err := json.NewDecoder(resp.Body).Decode(&updated); err != nil { + return nil, fmt.Errorf("decode updated lease: %w", err) + } + return &updated, nil +} + +func (c *LeaseClient) doRequest(ctx context.Context, method, url string, body any) (*http.Response, error) { + token, err := readToken() + if err != nil { + return nil, fmt.Errorf("read service account token: %w", err) + } + + var bodyReader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal request body: %w", err) + } + bodyReader = bytes.NewReader(data) + } + + req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + return c.httpClient.Do(req) +} + +func readToken() (string, error) { + data, err := os.ReadFile(saTokenPath) + if err != nil { + return "", fmt.Errorf("read %s: %w", saTokenPath, err) + } + return strings.TrimSpace(string(data)), nil +} + +func (c *LeaseClient) readError(resp *http.Response) error { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return fmt.Errorf("k8s API %s %d: %s", resp.Request.URL.Path, resp.StatusCode, string(body)) +} + +// LeaseNameForDomain returns a deterministic, DNS-label-safe Lease name +// for the given domain. The domain is hashed to avoid dots and length issues. +func LeaseNameForDomain(domain string) string { + h := sha256.Sum256([]byte(domain)) + return "cert-lock-" + hex.EncodeToString(h[:8]) +} + +// InCluster reports whether the process is running inside a Kubernetes pod +// by checking for the KUBERNETES_SERVICE_HOST environment variable. +func InCluster() bool { + _, exists := os.LookupEnv("KUBERNETES_SERVICE_HOST") + return exists +} diff --git a/proxy/internal/k8s/lease_test.go b/proxy/internal/k8s/lease_test.go new file mode 100644 index 000000000..9d5d3c6ce --- /dev/null +++ b/proxy/internal/k8s/lease_test.go @@ -0,0 +1,102 @@ +package k8s + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLeaseNameForDomain(t *testing.T) { + tests := []struct { + domain string + }{ + {"example.com"}, + {"app.example.com"}, + {"another.domain.io"}, + } + + seen := make(map[string]string) + for _, tc := range tests { + name := LeaseNameForDomain(tc.domain) + + assert.True(t, len(name) <= 63, "must be valid DNS label length") + assert.Regexp(t, `^cert-lock-[0-9a-f]{16}$`, name, + "must match expected format for domain %q", tc.domain) + + // Same input produces same output. + assert.Equal(t, name, LeaseNameForDomain(tc.domain), "must be deterministic") + + // Different domains produce different names. + if prev, ok := seen[name]; ok { + t.Errorf("collision: %q and %q both map to %s", prev, tc.domain, name) + } + seen[name] = tc.domain + } +} + +func TestMicroTimeJSON(t *testing.T) { + ts := time.Date(2024, 6, 15, 10, 30, 0, 0, time.UTC) + mt := &MicroTime{Time: ts} + + data, err := json.Marshal(mt) + require.NoError(t, err) + assert.Equal(t, `"2024-06-15T10:30:00.000000Z"`, string(data)) + + var decoded MicroTime + require.NoError(t, json.Unmarshal(data, &decoded)) + assert.True(t, ts.Equal(decoded.Time), "round-trip should preserve time") +} + +func TestMicroTimeNullJSON(t *testing.T) { + // Null pointer serializes as JSON null via the Lease struct. + spec := LeaseSpec{ + HolderIdentity: nil, + AcquireTime: nil, + RenewTime: nil, + } + + data, err := json.Marshal(spec) + require.NoError(t, err) + assert.Contains(t, string(data), `"acquireTime":null`) + assert.Contains(t, string(data), `"renewTime":null`) +} + +func TestLeaseJSONRoundTrip(t *testing.T) { + holder := "pod-abc" + dur := int32(300) + now := MicroTime{Time: time.Now().UTC().Truncate(time.Microsecond)} + + original := Lease{ + APIVersion: "coordination.k8s.io/v1", + Kind: "Lease", + Metadata: LeaseMetadata{ + Name: "cert-lock-abcdef0123456789", + Namespace: "default", + ResourceVersion: "12345", + Annotations: map[string]string{ + "netbird.io/domain": "app.example.com", + }, + }, + Spec: LeaseSpec{ + HolderIdentity: &holder, + LeaseDurationSeconds: &dur, + AcquireTime: &now, + RenewTime: &now, + }, + } + + data, err := json.Marshal(original) + require.NoError(t, err) + + var decoded Lease + require.NoError(t, json.Unmarshal(data, &decoded)) + + assert.Equal(t, original.Metadata.Name, decoded.Metadata.Name) + assert.Equal(t, original.Metadata.ResourceVersion, decoded.Metadata.ResourceVersion) + assert.Equal(t, *original.Spec.HolderIdentity, *decoded.Spec.HolderIdentity) + assert.Equal(t, *original.Spec.LeaseDurationSeconds, *decoded.Spec.LeaseDurationSeconds) + assert.True(t, original.Spec.AcquireTime.Equal(decoded.Spec.AcquireTime.Time)) +} diff --git a/proxy/internal/metrics/l4_metrics_test.go b/proxy/internal/metrics/l4_metrics_test.go new file mode 100644 index 000000000..055158828 --- /dev/null +++ b/proxy/internal/metrics/l4_metrics_test.go @@ -0,0 +1,69 @@ +package metrics_test + +import ( + "context" + "reflect" + "testing" + "time" + + promexporter "go.opentelemetry.io/otel/exporters/prometheus" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + + "github.com/netbirdio/netbird/proxy/internal/metrics" + "github.com/netbirdio/netbird/proxy/internal/types" +) + +func newTestMetrics(t *testing.T) *metrics.Metrics { + t.Helper() + + exporter, err := promexporter.New() + if err != nil { + t.Fatalf("create prometheus exporter: %v", err) + } + + provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(exporter)) + pkg := reflect.TypeOf(metrics.Metrics{}).PkgPath() + meter := provider.Meter(pkg) + + m, err := metrics.New(context.Background(), meter) + if err != nil { + t.Fatalf("create metrics: %v", err) + } + return m +} + +func TestL4ServiceGauge(t *testing.T) { + m := newTestMetrics(t) + + m.L4ServiceAdded(types.ServiceModeTCP) + m.L4ServiceAdded(types.ServiceModeTCP) + m.L4ServiceAdded(types.ServiceModeUDP) + m.L4ServiceRemoved(types.ServiceModeTCP) +} + +func TestTCPRelayMetrics(t *testing.T) { + m := newTestMetrics(t) + + acct := types.AccountID("acct-1") + + m.TCPRelayStarted(acct) + m.TCPRelayStarted(acct) + m.TCPRelayEnded(acct, 10*time.Second, 1000, 500) + m.TCPRelayDialError(acct) + m.TCPRelayRejected(acct) +} + +func TestUDPSessionMetrics(t *testing.T) { + m := newTestMetrics(t) + + acct := types.AccountID("acct-2") + + m.UDPSessionStarted(acct) + m.UDPSessionStarted(acct) + m.UDPSessionEnded(acct) + m.UDPSessionDialError(acct) + m.UDPSessionRejected(acct) + m.UDPPacketRelayed(types.RelayDirectionClientToBackend, 100) + m.UDPPacketRelayed(types.RelayDirectionClientToBackend, 200) + m.UDPPacketRelayed(types.RelayDirectionBackendToClient, 150) +} diff --git a/proxy/internal/metrics/metrics.go b/proxy/internal/metrics/metrics.go new file mode 100644 index 000000000..573485625 --- /dev/null +++ b/proxy/internal/metrics/metrics.go @@ -0,0 +1,389 @@ +package metrics + +import ( + "context" + "net/http" + "sync" + "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + + "github.com/netbirdio/netbird/proxy/internal/proxy" + "github.com/netbirdio/netbird/proxy/internal/responsewriter" + "github.com/netbirdio/netbird/proxy/internal/types" +) + +// Metrics collects OpenTelemetry metrics for the proxy. +type Metrics struct { + ctx context.Context + requestsTotal metric.Int64Counter + activeRequests metric.Int64UpDownCounter + configuredDomains metric.Int64UpDownCounter + totalPaths metric.Int64UpDownCounter + requestDuration metric.Int64Histogram + backendDuration metric.Int64Histogram + certificateIssueDuration metric.Int64Histogram + + // L4 service-level metrics. + l4Services metric.Int64UpDownCounter + + // L4 TCP connection-level metrics. + tcpActiveConns metric.Int64UpDownCounter + tcpConnsTotal metric.Int64Counter + tcpConnDuration metric.Int64Histogram + tcpBytesTotal metric.Int64Counter + + // L4 UDP session-level metrics. + udpActiveSess metric.Int64UpDownCounter + udpSessionsTotal metric.Int64Counter + udpPacketsTotal metric.Int64Counter + udpBytesTotal metric.Int64Counter + + mappingsMux sync.Mutex + mappingPaths map[string]int +} + +// New creates a Metrics instance using the given OpenTelemetry meter. +func New(ctx context.Context, meter metric.Meter) (*Metrics, error) { + m := &Metrics{ + ctx: ctx, + mappingPaths: make(map[string]int), + } + + if err := m.initHTTPMetrics(meter); err != nil { + return nil, err + } + if err := m.initL4Metrics(meter); err != nil { + return nil, err + } + + return m, nil +} + +func (m *Metrics) initHTTPMetrics(meter metric.Meter) error { + var err error + + m.requestsTotal, err = meter.Int64Counter( + "proxy.http.request.counter", + metric.WithUnit("1"), + metric.WithDescription("Total number of requests made to the netbird proxy"), + ) + if err != nil { + return err + } + + m.activeRequests, err = meter.Int64UpDownCounter( + "proxy.http.active_requests", + metric.WithUnit("1"), + metric.WithDescription("Current in-flight requests handled by the netbird proxy"), + ) + if err != nil { + return err + } + + m.configuredDomains, err = meter.Int64UpDownCounter( + "proxy.domains.count", + metric.WithUnit("1"), + metric.WithDescription("Current number of domains configured on the netbird proxy"), + ) + if err != nil { + return err + } + + m.totalPaths, err = meter.Int64UpDownCounter( + "proxy.paths.count", + metric.WithUnit("1"), + metric.WithDescription("Total number of paths configured on the netbird proxy"), + ) + if err != nil { + return err + } + + m.requestDuration, err = meter.Int64Histogram( + "proxy.http.request.duration.ms", + metric.WithUnit("milliseconds"), + metric.WithDescription("Duration of requests made to the netbird proxy"), + ) + if err != nil { + return err + } + + m.backendDuration, err = meter.Int64Histogram( + "proxy.backend.duration.ms", + metric.WithUnit("milliseconds"), + metric.WithDescription("Duration of peer round trip time from the netbird proxy"), + ) + if err != nil { + return err + } + + m.certificateIssueDuration, err = meter.Int64Histogram( + "proxy.certificate.issue.duration.ms", + metric.WithUnit("milliseconds"), + metric.WithDescription("Duration of ACME certificate issuance"), + ) + return err +} + +func (m *Metrics) initL4Metrics(meter metric.Meter) error { + var err error + + m.l4Services, err = meter.Int64UpDownCounter( + "proxy.l4.services.count", + metric.WithUnit("1"), + metric.WithDescription("Current number of configured L4 services (TCP/TLS/UDP) by mode"), + ) + if err != nil { + return err + } + + m.tcpActiveConns, err = meter.Int64UpDownCounter( + "proxy.tcp.active_connections", + metric.WithUnit("1"), + metric.WithDescription("Current number of active TCP/TLS relay connections"), + ) + if err != nil { + return err + } + + m.tcpConnsTotal, err = meter.Int64Counter( + "proxy.tcp.connections.total", + metric.WithUnit("1"), + metric.WithDescription("Total TCP/TLS relay connections by result and account"), + ) + if err != nil { + return err + } + + m.tcpConnDuration, err = meter.Int64Histogram( + "proxy.tcp.connection.duration.ms", + metric.WithUnit("milliseconds"), + metric.WithDescription("Duration of TCP/TLS relay connections"), + ) + if err != nil { + return err + } + + m.tcpBytesTotal, err = meter.Int64Counter( + "proxy.tcp.bytes.total", + metric.WithUnit("bytes"), + metric.WithDescription("Total bytes transferred through TCP/TLS relay by direction"), + ) + if err != nil { + return err + } + + m.udpActiveSess, err = meter.Int64UpDownCounter( + "proxy.udp.active_sessions", + metric.WithUnit("1"), + metric.WithDescription("Current number of active UDP relay sessions"), + ) + if err != nil { + return err + } + + m.udpSessionsTotal, err = meter.Int64Counter( + "proxy.udp.sessions.total", + metric.WithUnit("1"), + metric.WithDescription("Total UDP relay sessions by result and account"), + ) + if err != nil { + return err + } + + m.udpPacketsTotal, err = meter.Int64Counter( + "proxy.udp.packets.total", + metric.WithUnit("1"), + metric.WithDescription("Total UDP packets relayed by direction"), + ) + if err != nil { + return err + } + + m.udpBytesTotal, err = meter.Int64Counter( + "proxy.udp.bytes.total", + metric.WithUnit("bytes"), + metric.WithDescription("Total bytes transferred through UDP relay by direction"), + ) + return err +} + +type responseInterceptor struct { + *responsewriter.PassthroughWriter + status int + size int +} + +func (w *responseInterceptor) WriteHeader(status int) { + w.status = status + w.PassthroughWriter.WriteHeader(status) +} + +func (w *responseInterceptor) Write(b []byte) (int, error) { + size, err := w.PassthroughWriter.Write(b) + w.size += size + return size, err +} + +// Unwrap returns the underlying ResponseWriter so http.ResponseController +// can reach through to the original writer for Hijack/Flush operations. +func (w *responseInterceptor) Unwrap() http.ResponseWriter { + return w.PassthroughWriter +} + +// Middleware wraps an HTTP handler with request metrics. +func (m *Metrics) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + m.requestsTotal.Add(m.ctx, 1) + m.activeRequests.Add(m.ctx, 1) + + interceptor := &responseInterceptor{PassthroughWriter: responsewriter.New(w)} + + start := time.Now() + defer func() { + duration := time.Since(start) + m.activeRequests.Add(m.ctx, -1) + m.requestDuration.Record(m.ctx, duration.Milliseconds()) + }() + + next.ServeHTTP(interceptor, r) + }) +} + +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { + return f(r) +} + +// RoundTripper wraps an http.RoundTripper with backend duration metrics. +func (m *Metrics) RoundTripper(next http.RoundTripper) http.RoundTripper { + return roundTripperFunc(func(req *http.Request) (*http.Response, error) { + start := time.Now() + res, err := next.RoundTrip(req) + duration := time.Since(start) + + m.backendDuration.Record(m.ctx, duration.Milliseconds()) + + return res, err + }) +} + +// AddMapping records that a domain mapping was added. +func (m *Metrics) AddMapping(mapping proxy.Mapping) { + m.mappingsMux.Lock() + defer m.mappingsMux.Unlock() + + newPathCount := len(mapping.Paths) + oldPathCount, exists := m.mappingPaths[mapping.Host] + + if !exists { + m.configuredDomains.Add(m.ctx, 1) + } + + pathDelta := newPathCount - oldPathCount + if pathDelta != 0 { + m.totalPaths.Add(m.ctx, int64(pathDelta)) + } + + m.mappingPaths[mapping.Host] = newPathCount +} + +// RemoveMapping records that a domain mapping was removed. +func (m *Metrics) RemoveMapping(mapping proxy.Mapping) { + m.mappingsMux.Lock() + defer m.mappingsMux.Unlock() + + oldPathCount, exists := m.mappingPaths[mapping.Host] + if !exists { + return + } + + m.configuredDomains.Add(m.ctx, -1) + m.totalPaths.Add(m.ctx, -int64(oldPathCount)) + + delete(m.mappingPaths, mapping.Host) +} + +// RecordCertificateIssuance records the duration of a certificate issuance. +func (m *Metrics) RecordCertificateIssuance(duration time.Duration) { + m.certificateIssueDuration.Record(m.ctx, duration.Milliseconds()) +} + +// L4ServiceAdded increments the L4 service gauge for the given mode. +func (m *Metrics) L4ServiceAdded(mode types.ServiceMode) { + m.l4Services.Add(m.ctx, 1, metric.WithAttributes(attribute.String("mode", string(mode)))) +} + +// L4ServiceRemoved decrements the L4 service gauge for the given mode. +func (m *Metrics) L4ServiceRemoved(mode types.ServiceMode) { + m.l4Services.Add(m.ctx, -1, metric.WithAttributes(attribute.String("mode", string(mode)))) +} + +// TCPRelayStarted records a new TCP relay connection starting. +func (m *Metrics) TCPRelayStarted(accountID types.AccountID) { + acct := attribute.String("account_id", string(accountID)) + m.tcpActiveConns.Add(m.ctx, 1, metric.WithAttributes(acct)) + m.tcpConnsTotal.Add(m.ctx, 1, metric.WithAttributes(acct, attribute.String("result", "success"))) +} + +// TCPRelayEnded records a TCP relay connection ending and accumulates bytes and duration. +func (m *Metrics) TCPRelayEnded(accountID types.AccountID, duration time.Duration, srcToDst, dstToSrc int64) { + acct := attribute.String("account_id", string(accountID)) + m.tcpActiveConns.Add(m.ctx, -1, metric.WithAttributes(acct)) + m.tcpConnDuration.Record(m.ctx, duration.Milliseconds(), metric.WithAttributes(acct)) + m.tcpBytesTotal.Add(m.ctx, srcToDst, metric.WithAttributes(attribute.String("direction", "client_to_backend"))) + m.tcpBytesTotal.Add(m.ctx, dstToSrc, metric.WithAttributes(attribute.String("direction", "backend_to_client"))) +} + +// TCPRelayDialError records a dial failure for a TCP relay. +func (m *Metrics) TCPRelayDialError(accountID types.AccountID) { + m.tcpConnsTotal.Add(m.ctx, 1, metric.WithAttributes( + attribute.String("account_id", string(accountID)), + attribute.String("result", "dial_error"), + )) +} + +// TCPRelayRejected records a rejected TCP relay (semaphore full). +func (m *Metrics) TCPRelayRejected(accountID types.AccountID) { + m.tcpConnsTotal.Add(m.ctx, 1, metric.WithAttributes( + attribute.String("account_id", string(accountID)), + attribute.String("result", "rejected"), + )) +} + +// UDPSessionStarted records a new UDP session starting. +func (m *Metrics) UDPSessionStarted(accountID types.AccountID) { + acct := attribute.String("account_id", string(accountID)) + m.udpActiveSess.Add(m.ctx, 1, metric.WithAttributes(acct)) + m.udpSessionsTotal.Add(m.ctx, 1, metric.WithAttributes(acct, attribute.String("result", "success"))) +} + +// UDPSessionEnded records a UDP session ending. +func (m *Metrics) UDPSessionEnded(accountID types.AccountID) { + m.udpActiveSess.Add(m.ctx, -1, metric.WithAttributes(attribute.String("account_id", string(accountID)))) +} + +// UDPSessionDialError records a dial failure for a UDP session. +func (m *Metrics) UDPSessionDialError(accountID types.AccountID) { + m.udpSessionsTotal.Add(m.ctx, 1, metric.WithAttributes( + attribute.String("account_id", string(accountID)), + attribute.String("result", "dial_error"), + )) +} + +// UDPSessionRejected records a rejected UDP session (limit or rate limited). +func (m *Metrics) UDPSessionRejected(accountID types.AccountID) { + m.udpSessionsTotal.Add(m.ctx, 1, metric.WithAttributes( + attribute.String("account_id", string(accountID)), + attribute.String("result", "rejected"), + )) +} + +// UDPPacketRelayed records a packet relayed in the given direction with its size in bytes. +func (m *Metrics) UDPPacketRelayed(direction types.RelayDirection, bytes int) { + dir := attribute.String("direction", string(direction)) + m.udpPacketsTotal.Add(m.ctx, 1, metric.WithAttributes(dir)) + m.udpBytesTotal.Add(m.ctx, int64(bytes), metric.WithAttributes(dir)) +} diff --git a/proxy/internal/metrics/metrics_test.go b/proxy/internal/metrics/metrics_test.go new file mode 100644 index 000000000..f81072eda --- /dev/null +++ b/proxy/internal/metrics/metrics_test.go @@ -0,0 +1,83 @@ +package metrics_test + +import ( + "context" + "net/http" + "net/url" + "reflect" + "testing" + + "github.com/google/go-cmp/cmp" + "go.opentelemetry.io/otel/exporters/prometheus" + "go.opentelemetry.io/otel/sdk/metric" + + "github.com/netbirdio/netbird/proxy/internal/metrics" +) + +type testRoundTripper struct { + response *http.Response + err error +} + +func (t *testRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + return t.response, t.err +} + +func TestMetrics_RoundTripper(t *testing.T) { + testResponse := http.Response{ + StatusCode: http.StatusOK, + Body: http.NoBody, + } + + tests := map[string]struct { + roundTripper http.RoundTripper + request *http.Request + response *http.Response + err error + }{ + "ok": { + roundTripper: &testRoundTripper{response: &testResponse}, + request: &http.Request{Method: "GET", URL: &url.URL{Path: "/foo"}}, + response: &testResponse, + }, + "nil url": { + roundTripper: &testRoundTripper{response: &testResponse}, + request: &http.Request{Method: "GET", URL: nil}, + response: &testResponse, + }, + "nil response": { + roundTripper: &testRoundTripper{response: nil}, + request: &http.Request{Method: "GET", URL: &url.URL{Path: "/foo"}}, + }, + } + + exporter, err := prometheus.New() + if err != nil { + t.Fatalf("create prometheus exporter: %v", err) + } + + provider := metric.NewMeterProvider(metric.WithReader(exporter)) + pkg := reflect.TypeOf(metrics.Metrics{}).PkgPath() + meter := provider.Meter(pkg) + + m, err := metrics.New(context.Background(), meter) + if err != nil { + t.Fatalf("create metrics: %v", err) + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + rt := m.RoundTripper(test.roundTripper) + res, err := rt.RoundTrip(test.request) + if res != nil && res.Body != nil { + defer res.Body.Close() + } + if diff := cmp.Diff(test.err, err); diff != "" { + t.Errorf("Incorrect error (-want +got):\n%s", diff) + } + if diff := cmp.Diff(test.response, res); diff != "" { + t.Errorf("Incorrect response (-want +got):\n%s", diff) + } + }) + } +} diff --git a/proxy/internal/netutil/errors.go b/proxy/internal/netutil/errors.go new file mode 100644 index 000000000..ff24e33d4 --- /dev/null +++ b/proxy/internal/netutil/errors.go @@ -0,0 +1,40 @@ +package netutil + +import ( + "context" + "errors" + "fmt" + "io" + "math" + "net" + "syscall" +) + +// ValidatePort converts an int32 proto port to uint16, returning an error +// if the value is out of the valid 1–65535 range. +func ValidatePort(port int32) (uint16, error) { + if port <= 0 || port > math.MaxUint16 { + return 0, fmt.Errorf("invalid port %d: must be 1–65535", port) + } + return uint16(port), nil +} + +// IsExpectedError returns true for errors that are normal during +// connection teardown and should not be logged as warnings. +func IsExpectedError(err error) bool { + return errors.Is(err, net.ErrClosed) || + errors.Is(err, context.Canceled) || + errors.Is(err, io.EOF) || + errors.Is(err, syscall.ECONNRESET) || + errors.Is(err, syscall.EPIPE) || + errors.Is(err, syscall.ECONNABORTED) +} + +// IsTimeout checks whether the error is a network timeout. +func IsTimeout(err error) bool { + var netErr net.Error + if errors.As(err, &netErr) { + return netErr.Timeout() + } + return false +} diff --git a/proxy/internal/netutil/errors_test.go b/proxy/internal/netutil/errors_test.go new file mode 100644 index 000000000..7d6be10ff --- /dev/null +++ b/proxy/internal/netutil/errors_test.go @@ -0,0 +1,92 @@ +package netutil + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "syscall" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValidatePort(t *testing.T) { + tests := []struct { + name string + port int32 + want uint16 + wantErr bool + }{ + {"valid min", 1, 1, false}, + {"valid mid", 8080, 8080, false}, + {"valid max", 65535, 65535, false}, + {"zero", 0, 0, true}, + {"negative", -1, 0, true}, + {"too large", 65536, 0, true}, + {"way too large", 100000, 0, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ValidatePort(tt.port) + if tt.wantErr { + assert.Error(t, err) + assert.Zero(t, got) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + } + }) + } +} + +func TestIsExpectedError(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {"net.ErrClosed", net.ErrClosed, true}, + {"context.Canceled", context.Canceled, true}, + {"io.EOF", io.EOF, true}, + {"ECONNRESET", syscall.ECONNRESET, true}, + {"EPIPE", syscall.EPIPE, true}, + {"ECONNABORTED", syscall.ECONNABORTED, true}, + {"wrapped expected", fmt.Errorf("wrap: %w", net.ErrClosed), true}, + {"unexpected EOF", io.ErrUnexpectedEOF, false}, + {"generic error", errors.New("something"), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, IsExpectedError(tt.err)) + }) + } +} + +type timeoutErr struct{ timeout bool } + +func (e *timeoutErr) Error() string { return "timeout" } +func (e *timeoutErr) Timeout() bool { return e.timeout } +func (e *timeoutErr) Temporary() bool { return false } + +func TestIsTimeout(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {"net timeout", &timeoutErr{timeout: true}, true}, + {"net non-timeout", &timeoutErr{timeout: false}, false}, + {"wrapped timeout", fmt.Errorf("wrap: %w", &timeoutErr{timeout: true}), true}, + {"generic error", errors.New("not a timeout"), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, IsTimeout(tt.err)) + }) + } +} diff --git a/proxy/internal/proxy/context.go b/proxy/internal/proxy/context.go new file mode 100644 index 000000000..d3f67dc57 --- /dev/null +++ b/proxy/internal/proxy/context.go @@ -0,0 +1,166 @@ +package proxy + +import ( + "context" + "net/netip" + "sync" + + "github.com/netbirdio/netbird/proxy/internal/types" +) + +type requestContextKey string + +const ( + capturedDataKey requestContextKey = "capturedData" +) + +// ResponseOrigin indicates where a response was generated. +type ResponseOrigin int + +const ( + // OriginBackend means the response came from the backend service. + OriginBackend ResponseOrigin = iota + // OriginNoRoute means the proxy had no matching host or path. + OriginNoRoute + // OriginProxyError means the proxy failed to reach the backend. + OriginProxyError + // OriginAuth means the proxy intercepted the request for authentication. + OriginAuth +) + +func (o ResponseOrigin) String() string { + switch o { + case OriginNoRoute: + return "no_route" + case OriginProxyError: + return "proxy_error" + case OriginAuth: + return "auth" + default: + return "backend" + } +} + +// CapturedData is a mutable struct that allows downstream handlers +// to pass data back up the middleware chain. +type CapturedData struct { + mu sync.RWMutex + requestID string + serviceID types.ServiceID + accountID types.AccountID + origin ResponseOrigin + clientIP netip.Addr + userID string + authMethod string +} + +// NewCapturedData creates a CapturedData with the given request ID. +func NewCapturedData(requestID string) *CapturedData { + return &CapturedData{requestID: requestID} +} + +// GetRequestID returns the request ID. +func (c *CapturedData) GetRequestID() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.requestID +} + +// SetServiceID sets the service ID. +func (c *CapturedData) SetServiceID(serviceID types.ServiceID) { + c.mu.Lock() + defer c.mu.Unlock() + c.serviceID = serviceID +} + +// GetServiceID returns the service ID. +func (c *CapturedData) GetServiceID() types.ServiceID { + c.mu.RLock() + defer c.mu.RUnlock() + return c.serviceID +} + +// SetAccountID sets the account ID. +func (c *CapturedData) SetAccountID(accountID types.AccountID) { + c.mu.Lock() + defer c.mu.Unlock() + c.accountID = accountID +} + +// GetAccountID returns the account ID. +func (c *CapturedData) GetAccountID() types.AccountID { + c.mu.RLock() + defer c.mu.RUnlock() + return c.accountID +} + +// SetOrigin sets the response origin. +func (c *CapturedData) SetOrigin(origin ResponseOrigin) { + c.mu.Lock() + defer c.mu.Unlock() + c.origin = origin +} + +// GetOrigin returns the response origin. +func (c *CapturedData) GetOrigin() ResponseOrigin { + c.mu.RLock() + defer c.mu.RUnlock() + return c.origin +} + +// SetClientIP sets the resolved client IP. +func (c *CapturedData) SetClientIP(ip netip.Addr) { + c.mu.Lock() + defer c.mu.Unlock() + c.clientIP = ip +} + +// GetClientIP returns the resolved client IP. +func (c *CapturedData) GetClientIP() netip.Addr { + c.mu.RLock() + defer c.mu.RUnlock() + return c.clientIP +} + +// SetUserID sets the authenticated user ID. +func (c *CapturedData) SetUserID(userID string) { + c.mu.Lock() + defer c.mu.Unlock() + c.userID = userID +} + +// GetUserID returns the authenticated user ID. +func (c *CapturedData) GetUserID() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.userID +} + +// SetAuthMethod sets the authentication method used. +func (c *CapturedData) SetAuthMethod(method string) { + c.mu.Lock() + defer c.mu.Unlock() + c.authMethod = method +} + +// GetAuthMethod returns the authentication method used. +func (c *CapturedData) GetAuthMethod() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.authMethod +} + +// WithCapturedData adds a CapturedData struct to the context. +func WithCapturedData(ctx context.Context, data *CapturedData) context.Context { + return context.WithValue(ctx, capturedDataKey, data) +} + +// CapturedDataFromContext retrieves the CapturedData from context. +func CapturedDataFromContext(ctx context.Context) *CapturedData { + v := ctx.Value(capturedDataKey) + data, ok := v.(*CapturedData) + if !ok { + return nil + } + return data +} diff --git a/proxy/internal/proxy/proxy_bench_test.go b/proxy/internal/proxy/proxy_bench_test.go new file mode 100644 index 000000000..b59ef75c0 --- /dev/null +++ b/proxy/internal/proxy/proxy_bench_test.go @@ -0,0 +1,136 @@ +package proxy_test + +import ( + "crypto/rand" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/netbirdio/netbird/proxy/internal/proxy" + "github.com/netbirdio/netbird/proxy/internal/types" +) + +type nopTransport struct{} + +func (nopTransport) RoundTrip(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: http.NoBody, + }, nil +} + +func BenchmarkServeHTTP(b *testing.B) { + rp := proxy.NewReverseProxy(nopTransport{}, "http", nil, nil) + rp.AddMapping(proxy.Mapping{ + ID: types.ServiceID(rand.Text()), + AccountID: types.AccountID(rand.Text()), + Host: "app.example.com", + Paths: map[string]*proxy.PathTarget{ + "/": { + URL: &url.URL{ + Scheme: "http", + Host: "10.0.0.1:8080", + }, + }, + }, + }) + + req := httptest.NewRequest(http.MethodGet, "http://app.example.com", nil) + req.Host = "app.example.com" + req.RemoteAddr = "203.0.113.50:12345" + + for b.Loop() { + rp.ServeHTTP(httptest.NewRecorder(), req) + } +} + +func BenchmarkServeHTTPHostCount(b *testing.B) { + hostCounts := []int{1, 10, 100, 1_000, 10_000} + + for _, hostCount := range hostCounts { + b.Run(fmt.Sprintf("hosts=%d", hostCount), func(b *testing.B) { + rp := proxy.NewReverseProxy(nopTransport{}, "http", nil, nil) + + var target string + targetIndex, err := rand.Int(rand.Reader, big.NewInt(int64(hostCount))) + if err != nil { + b.Fatal(err) + } + for i := range hostCount { + id := rand.Text() + host := fmt.Sprintf("%s.example.com", id) + if int64(i) == targetIndex.Int64() { + target = id + } + rp.AddMapping(proxy.Mapping{ + ID: types.ServiceID(id), + AccountID: types.AccountID(rand.Text()), + Host: host, + Paths: map[string]*proxy.PathTarget{ + "/": { + URL: &url.URL{ + Scheme: "http", + Host: "10.0.0.1:8080", + }, + }, + }, + }) + } + + req := httptest.NewRequest(http.MethodGet, "http://"+target+"/", nil) + req.Host = target + req.RemoteAddr = "203.0.113.50:12345" + + for b.Loop() { + rp.ServeHTTP(httptest.NewRecorder(), req) + } + }) + } +} + +func BenchmarkServeHTTPPathCount(b *testing.B) { + pathCounts := []int{1, 5, 10, 25, 50} + + for _, pathCount := range pathCounts { + b.Run(fmt.Sprintf("paths=%d", pathCount), func(b *testing.B) { + rp := proxy.NewReverseProxy(nopTransport{}, "http", nil, nil) + + var target string + targetIndex, err := rand.Int(rand.Reader, big.NewInt(int64(pathCount))) + if err != nil { + b.Fatal(err) + } + + paths := make(map[string]*proxy.PathTarget, pathCount) + for i := range pathCount { + path := "/" + rand.Text() + if int64(i) == targetIndex.Int64() { + target = path + } + paths[path] = &proxy.PathTarget{ + URL: &url.URL{ + Scheme: "http", + Host: "10.0.0.1:" + fmt.Sprintf("%d", 8080+i), + }, + } + } + rp.AddMapping(proxy.Mapping{ + ID: types.ServiceID(rand.Text()), + AccountID: types.AccountID(rand.Text()), + Host: "app.example.com", + Paths: paths, + }) + + req := httptest.NewRequest(http.MethodGet, "http://app.example.com"+target, nil) + req.Host = "app.example.com" + req.RemoteAddr = "203.0.113.50:12345" + + for b.Loop() { + rp.ServeHTTP(httptest.NewRecorder(), req) + } + }) + } +} diff --git a/proxy/internal/proxy/reverseproxy.go b/proxy/internal/proxy/reverseproxy.go new file mode 100644 index 000000000..246851d24 --- /dev/null +++ b/proxy/internal/proxy/reverseproxy.go @@ -0,0 +1,428 @@ +package proxy + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/http/httputil" + "net/netip" + "net/url" + "strings" + "sync" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/proxy/auth" + "github.com/netbirdio/netbird/proxy/internal/roundtrip" + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/proxy/web" +) + +type ReverseProxy struct { + transport http.RoundTripper + // forwardedProto overrides the X-Forwarded-Proto header value. + // Valid values: "auto" (detect from TLS), "http", "https". + forwardedProto string + // trustedProxies is a list of IP prefixes for trusted upstream proxies. + // When the direct connection comes from a trusted proxy, forwarding + // headers are preserved and appended to instead of being stripped. + trustedProxies []netip.Prefix + mappingsMux sync.RWMutex + mappings map[string]Mapping + logger *log.Logger +} + +// NewReverseProxy configures a new NetBird ReverseProxy. +// This is a wrapper around an httputil.ReverseProxy set +// to dynamically route requests based on internal mapping +// between requested URLs and targets. +// The internal mappings can be modified using the AddMapping +// and RemoveMapping functions. +func NewReverseProxy(transport http.RoundTripper, forwardedProto string, trustedProxies []netip.Prefix, logger *log.Logger) *ReverseProxy { + if logger == nil { + logger = log.StandardLogger() + } + return &ReverseProxy{ + transport: transport, + forwardedProto: forwardedProto, + trustedProxies: trustedProxies, + mappings: make(map[string]Mapping), + logger: logger, + } +} + +func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { + result, exists := p.findTargetForRequest(r) + if !exists { + if cd := CapturedDataFromContext(r.Context()); cd != nil { + cd.SetOrigin(OriginNoRoute) + } + requestID := getRequestID(r) + web.ServeErrorPage(w, r, http.StatusNotFound, "Service Not Found", + "The requested service could not be found. Please check the URL, try refreshing, or check if the peer is running. If that doesn't work, see our documentation for help.", + requestID, web.ErrorStatus{Proxy: true, Destination: false}) + return + } + + ctx := r.Context() + // Set the account ID in the context for the roundtripper to use. + ctx = roundtrip.WithAccountID(ctx, result.accountID) + + // Populate captured data if it exists (allows middleware to read after handler completes). + // This solves the problem of passing data UP the middleware chain: we put a mutable struct + // pointer in the context, and mutate the struct here so outer middleware can read it. + if capturedData := CapturedDataFromContext(ctx); capturedData != nil { + capturedData.SetServiceID(result.serviceID) + capturedData.SetAccountID(result.accountID) + } + + pt := result.target + + if pt.SkipTLSVerify { + ctx = roundtrip.WithSkipTLSVerify(ctx) + } + if pt.RequestTimeout > 0 { + ctx = types.WithDialTimeout(ctx, pt.RequestTimeout) + } + + rewriteMatchedPath := result.matchedPath + if pt.PathRewrite == PathRewritePreserve { + rewriteMatchedPath = "" + } + + rp := &httputil.ReverseProxy{ + Rewrite: p.rewriteFunc(pt.URL, rewriteMatchedPath, result.passHostHeader, pt.PathRewrite, pt.CustomHeaders, result.stripAuthHeaders), + Transport: p.transport, + FlushInterval: -1, + ErrorHandler: p.proxyErrorHandler, + } + if result.rewriteRedirects { + rp.ModifyResponse = p.rewriteLocationFunc(pt.URL, rewriteMatchedPath, r) //nolint:bodyclose + } + rp.ServeHTTP(w, r.WithContext(ctx)) +} + +// rewriteFunc returns a Rewrite function for httputil.ReverseProxy that rewrites +// inbound requests to target the backend service while setting security-relevant +// forwarding headers and stripping proxy authentication credentials. +// When passHostHeader is true, the original client Host header is preserved +// instead of being rewritten to the backend's address. +// The pathRewrite parameter controls how the request path is transformed. +func (p *ReverseProxy) rewriteFunc(target *url.URL, matchedPath string, passHostHeader bool, pathRewrite PathRewriteMode, customHeaders map[string]string, stripAuthHeaders []string) func(r *httputil.ProxyRequest) { + return func(r *httputil.ProxyRequest) { + switch pathRewrite { + case PathRewritePreserve: + // Keep the full original request path as-is. + default: + if matchedPath != "" && matchedPath != "/" { + // Strip the matched path prefix from the incoming request path before + // SetURL joins it with the target's base path, avoiding path duplication. + r.Out.URL.Path = strings.TrimPrefix(r.Out.URL.Path, matchedPath) + if r.Out.URL.Path == "" { + r.Out.URL.Path = "/" + } + r.Out.URL.RawPath = "" + } + } + + r.SetURL(target) + if passHostHeader { + r.Out.Host = r.In.Host + } else { + r.Out.Host = target.Host + } + + for _, h := range stripAuthHeaders { + r.Out.Header.Del(h) + } + + for k, v := range customHeaders { + r.Out.Header.Set(k, v) + } + + clientIP := extractHostIP(r.In.RemoteAddr) + + if isTrustedAddr(clientIP, p.trustedProxies) { + p.setTrustedForwardingHeaders(r, clientIP) + } else { + p.setUntrustedForwardingHeaders(r, clientIP) + } + + stripSessionCookie(r) + stripSessionTokenQuery(r) + } +} + +// rewriteLocationFunc returns a ModifyResponse function that rewrites Location +// headers in backend responses when they point to the backend's address, +// replacing them with the public-facing host and scheme. +func (p *ReverseProxy) rewriteLocationFunc(target *url.URL, matchedPath string, inReq *http.Request) func(*http.Response) error { + publicHost := inReq.Host + publicScheme := auth.ResolveProto(p.forwardedProto, inReq.TLS) + + return func(resp *http.Response) error { + location := resp.Header.Get("Location") + if location == "" { + return nil + } + + locURL, err := url.Parse(location) + if err != nil { + return fmt.Errorf("parse Location header %q: %w", location, err) + } + + // Only rewrite absolute URLs that point to the backend. + if locURL.Host == "" || !hostsEqual(locURL, target) { + return nil + } + + locURL.Host = publicHost + locURL.Scheme = publicScheme + + // Re-add the stripped path prefix so the client reaches the correct route. + // TrimRight prevents double slashes when matchedPath has a trailing slash. + if matchedPath != "" && matchedPath != "/" { + locURL.Path = strings.TrimRight(matchedPath, "/") + "/" + strings.TrimLeft(locURL.Path, "/") + } + + resp.Header.Set("Location", locURL.String()) + return nil + } +} + +// hostsEqual compares two URL authorities, normalizing default ports per +// RFC 3986 Section 6.2.3 (https://443 == https, http://80 == http). +func hostsEqual(a, b *url.URL) bool { + return normalizeHost(a) == normalizeHost(b) +} + +// normalizeHost strips the port from a URL's Host field if it matches the +// scheme's default port (443 for https, 80 for http). +func normalizeHost(u *url.URL) string { + host, port, err := net.SplitHostPort(u.Host) + if err != nil { + return u.Host + } + if (u.Scheme == "https" && port == "443") || (u.Scheme == "http" && port == "80") { + return host + } + return u.Host +} + +// setTrustedForwardingHeaders appends to the existing forwarding header chain +// and preserves upstream-provided headers when the direct connection is from +// a trusted proxy. +func (p *ReverseProxy) setTrustedForwardingHeaders(r *httputil.ProxyRequest, clientIP netip.Addr) { + ipStr := clientIP.String() + + // Append the direct connection IP to the existing X-Forwarded-For chain. + if existing := r.In.Header.Get("X-Forwarded-For"); existing != "" { + r.Out.Header.Set("X-Forwarded-For", existing+", "+ipStr) + } else { + r.Out.Header.Set("X-Forwarded-For", ipStr) + } + + // Preserve upstream X-Real-IP if present; otherwise resolve through the chain. + if realIP := r.In.Header.Get("X-Real-IP"); realIP != "" { + r.Out.Header.Set("X-Real-IP", realIP) + } else { + resolved := ResolveClientIP(r.In.RemoteAddr, r.In.Header.Get("X-Forwarded-For"), p.trustedProxies) + r.Out.Header.Set("X-Real-IP", resolved.String()) + } + + // Preserve upstream X-Forwarded-Host if present. + if fwdHost := r.In.Header.Get("X-Forwarded-Host"); fwdHost != "" { + r.Out.Header.Set("X-Forwarded-Host", fwdHost) + } else { + r.Out.Header.Set("X-Forwarded-Host", r.In.Host) + } + + // Trust upstream X-Forwarded-Proto; fall back to local resolution. + if fwdProto := r.In.Header.Get("X-Forwarded-Proto"); fwdProto != "" { + r.Out.Header.Set("X-Forwarded-Proto", fwdProto) + } else { + r.Out.Header.Set("X-Forwarded-Proto", auth.ResolveProto(p.forwardedProto, r.In.TLS)) + } + + // Trust upstream X-Forwarded-Port; fall back to local computation. + if fwdPort := r.In.Header.Get("X-Forwarded-Port"); fwdPort != "" { + r.Out.Header.Set("X-Forwarded-Port", fwdPort) + } else { + resolvedProto := r.Out.Header.Get("X-Forwarded-Proto") + r.Out.Header.Set("X-Forwarded-Port", extractForwardedPort(r.In.Host, resolvedProto)) + } +} + +// setUntrustedForwardingHeaders strips all incoming forwarding headers and +// sets them fresh based on the direct connection. This is the default +// behavior when no trusted proxies are configured or the direct connection +// is from an untrusted source. +func (p *ReverseProxy) setUntrustedForwardingHeaders(r *httputil.ProxyRequest, clientIP netip.Addr) { + ipStr := clientIP.String() + proto := auth.ResolveProto(p.forwardedProto, r.In.TLS) + r.Out.Header.Set("X-Forwarded-For", ipStr) + r.Out.Header.Set("X-Real-IP", ipStr) + r.Out.Header.Set("X-Forwarded-Host", r.In.Host) + r.Out.Header.Set("X-Forwarded-Proto", proto) + r.Out.Header.Set("X-Forwarded-Port", extractForwardedPort(r.In.Host, proto)) +} + +// stripSessionCookie removes the proxy's session cookie from the outgoing +// request while preserving all other cookies. +func stripSessionCookie(r *httputil.ProxyRequest) { + cookies := r.In.Cookies() + r.Out.Header.Del("Cookie") + for _, c := range cookies { + if c.Name != auth.SessionCookieName { + r.Out.AddCookie(c) + } + } +} + +// stripSessionTokenQuery removes the OIDC session_token query parameter from +// the outgoing URL to prevent credential leakage to backends. +func stripSessionTokenQuery(r *httputil.ProxyRequest) { + q := r.Out.URL.Query() + if q.Has("session_token") { + q.Del("session_token") + r.Out.URL.RawQuery = q.Encode() + } +} + +// extractForwardedPort returns the port from the Host header if present, +// otherwise defaults to the standard port for the resolved protocol. +func extractForwardedPort(host, resolvedProto string) string { + _, port, err := net.SplitHostPort(host) + if err == nil && port != "" { + return port + } + if resolvedProto == "https" { + return "443" + } + return "80" +} + +// proxyErrorHandler handles errors from the reverse proxy and serves +// user-friendly error pages instead of raw error responses. +func (p *ReverseProxy) proxyErrorHandler(w http.ResponseWriter, r *http.Request, err error) { + if cd := CapturedDataFromContext(r.Context()); cd != nil { + cd.SetOrigin(OriginProxyError) + } + requestID := getRequestID(r) + clientIP := getClientIP(r) + title, message, code, status := classifyProxyError(err) + + p.logger.Warnf("proxy error: request_id=%s client_ip=%s method=%s host=%s path=%s status=%d title=%q err=%v", + requestID, clientIP, r.Method, r.Host, r.URL.Path, code, title, err) + + web.ServeErrorPage(w, r, code, title, message, requestID, status) +} + +// getClientIP retrieves the resolved client IP string from context. +func getClientIP(r *http.Request) string { + if capturedData := CapturedDataFromContext(r.Context()); capturedData != nil { + if ip := capturedData.GetClientIP(); ip.IsValid() { + return ip.String() + } + } + return "" +} + +// getRequestID retrieves the request ID from context or returns empty string. +func getRequestID(r *http.Request) string { + if capturedData := CapturedDataFromContext(r.Context()); capturedData != nil { + return capturedData.GetRequestID() + } + return "" +} + +// classifyProxyError determines the appropriate error title, message, HTTP +// status code, and component status based on the error type. +func classifyProxyError(err error) (title, message string, code int, status web.ErrorStatus) { + switch { + case errors.Is(err, context.DeadlineExceeded), + isNetTimeout(err): + return "Request Timeout", + "The request timed out while trying to reach the service. Please refresh the page and try again.", + http.StatusGatewayTimeout, + web.ErrorStatus{Proxy: true, Destination: false} + + case errors.Is(err, context.Canceled): + return "Request Canceled", + "The request was canceled before it could be completed. Please refresh the page and try again.", + http.StatusBadGateway, + web.ErrorStatus{Proxy: true, Destination: false} + + case errors.Is(err, roundtrip.ErrNoAccountID): + return "Configuration Error", + "The request could not be processed due to a configuration issue. Please refresh the page and try again.", + http.StatusInternalServerError, + web.ErrorStatus{Proxy: false, Destination: false} + + case errors.Is(err, roundtrip.ErrNoPeerConnection), + errors.Is(err, roundtrip.ErrClientStartFailed): + return "Proxy Not Connected", + "The proxy is not connected to the NetBird network. Please try again later or contact your administrator.", + http.StatusBadGateway, + web.ErrorStatus{Proxy: false, Destination: false} + + case errors.Is(err, roundtrip.ErrTooManyInflight): + return "Service Overloaded", + "The service is currently handling too many requests. Please try again shortly.", + http.StatusServiceUnavailable, + web.ErrorStatus{Proxy: true, Destination: false} + + case isConnectionRefused(err): + return "Service Unavailable", + "The connection to the service was refused. Please verify that the service is running and try again.", + http.StatusBadGateway, + web.ErrorStatus{Proxy: true, Destination: false} + + case isHostUnreachable(err): + return "Peer Not Connected", + "The connection to the peer could not be established. Please ensure the peer is running and connected to the NetBird network.", + http.StatusBadGateway, + web.ErrorStatus{Proxy: true, Destination: false} + } + + return "Connection Error", + "An unexpected error occurred while connecting to the service. Please try again later.", + http.StatusBadGateway, + web.ErrorStatus{Proxy: true, Destination: false} +} + +// isConnectionRefused checks for connection refused errors by inspecting +// the inner error of a *net.OpError. This handles both standard net errors +// (where the inner error is a *os.SyscallError with "connection refused") +// and gVisor netstack errors ("connection was refused"). +func isConnectionRefused(err error) bool { + return opErrorContains(err, "refused") +} + +// isHostUnreachable checks for host/network unreachable errors by inspecting +// the inner error of a *net.OpError. Covers standard net ("no route to host", +// "network is unreachable") and gVisor ("host is unreachable", etc.). +func isHostUnreachable(err error) bool { + return opErrorContains(err, "unreachable") || opErrorContains(err, "no route to host") +} + +// isNetTimeout checks whether the error is a network timeout using the +// net.Error interface. +func isNetTimeout(err error) bool { + var netErr net.Error + return errors.As(err, &netErr) && netErr.Timeout() +} + +// opErrorContains extracts the inner error from a *net.OpError and checks +// whether its message contains the given substring. This handles gVisor +// netstack errors which wrap tcpip errors as plain strings rather than +// syscall.Errno values. +func opErrorContains(err error, substr string) bool { + var opErr *net.OpError + if errors.As(err, &opErr) && opErr.Err != nil { + return strings.Contains(opErr.Err.Error(), substr) + } + return false +} diff --git a/proxy/internal/proxy/reverseproxy_test.go b/proxy/internal/proxy/reverseproxy_test.go new file mode 100644 index 000000000..c53307837 --- /dev/null +++ b/proxy/internal/proxy/reverseproxy_test.go @@ -0,0 +1,1069 @@ +package proxy + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/netip" + "net/url" + "os" + "syscall" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/auth" + "github.com/netbirdio/netbird/proxy/internal/roundtrip" + "github.com/netbirdio/netbird/proxy/web" +) + +func TestRewriteFunc_HostRewriting(t *testing.T) { + target, _ := url.Parse("http://backend.internal:8080") + p := &ReverseProxy{forwardedProto: "auto"} + + t.Run("rewrites host to backend by default", func(t *testing.T) { + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "https://public.example.com/path", "203.0.113.1:12345") + + rewrite(pr) + + assert.Equal(t, "backend.internal:8080", pr.Out.Host) + }) + + t.Run("preserves original host when passHostHeader is true", func(t *testing.T) { + rewrite := p.rewriteFunc(target, "", true, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "https://public.example.com/path", "203.0.113.1:12345") + + rewrite(pr) + + assert.Equal(t, "public.example.com", pr.Out.Host, + "Host header should be the original client host") + assert.Equal(t, "backend.internal:8080", pr.Out.URL.Host, + "URL host (used for TLS/SNI) must still point to the backend") + }) +} + +func TestRewriteFunc_XForwardedForStripping(t *testing.T) { + target, _ := url.Parse("http://backend.internal:8080") + p := &ReverseProxy{forwardedProto: "auto"} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + t.Run("sets X-Forwarded-For from direct connection IP", func(t *testing.T) { + pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999") + + rewrite(pr) + + assert.Equal(t, "203.0.113.50", pr.Out.Header.Get("X-Forwarded-For"), + "should be set to the connecting client IP") + }) + + t.Run("strips spoofed X-Forwarded-For from client", func(t *testing.T) { + pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999") + pr.In.Header.Set("X-Forwarded-For", "10.0.0.1, 172.16.0.1") + + rewrite(pr) + + assert.Equal(t, "203.0.113.50", pr.Out.Header.Get("X-Forwarded-For"), + "spoofed XFF must be replaced, not appended to") + }) + + t.Run("strips spoofed X-Real-IP from client", func(t *testing.T) { + pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999") + pr.In.Header.Set("X-Real-IP", "10.0.0.1") + + rewrite(pr) + + assert.Equal(t, "203.0.113.50", pr.Out.Header.Get("X-Real-IP"), + "spoofed X-Real-IP must be replaced") + }) +} + +func TestRewriteFunc_ForwardedHostAndProto(t *testing.T) { + target, _ := url.Parse("http://backend.internal:8080") + + t.Run("sets X-Forwarded-Host to original host", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto"} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "http://myapp.example.com:8443/path", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "myapp.example.com:8443", pr.Out.Header.Get("X-Forwarded-Host")) + }) + + t.Run("sets X-Forwarded-Port from explicit host port", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto"} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "http://example.com:8443/path", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "8443", pr.Out.Header.Get("X-Forwarded-Port")) + }) + + t.Run("defaults X-Forwarded-Port to 443 for https", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto"} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "https://example.com/", "1.2.3.4:5000") + pr.In.TLS = &tls.ConnectionState{} + + rewrite(pr) + + assert.Equal(t, "443", pr.Out.Header.Get("X-Forwarded-Port")) + }) + + t.Run("defaults X-Forwarded-Port to 80 for http", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto"} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "80", pr.Out.Header.Get("X-Forwarded-Port")) + }) + + t.Run("auto detects https from TLS", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto"} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "https://example.com/", "1.2.3.4:5000") + pr.In.TLS = &tls.ConnectionState{} + + rewrite(pr) + + assert.Equal(t, "https", pr.Out.Header.Get("X-Forwarded-Proto")) + }) + + t.Run("auto detects http without TLS", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto"} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "http", pr.Out.Header.Get("X-Forwarded-Proto")) + }) + + t.Run("forced proto overrides TLS detection", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "https"} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") + // No TLS, but forced to https + + rewrite(pr) + + assert.Equal(t, "https", pr.Out.Header.Get("X-Forwarded-Proto")) + }) + + t.Run("forced http proto", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "http"} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "https://example.com/", "1.2.3.4:5000") + pr.In.TLS = &tls.ConnectionState{} + + rewrite(pr) + + assert.Equal(t, "http", pr.Out.Header.Get("X-Forwarded-Proto")) + }) +} + +func TestRewriteFunc_SessionCookieStripping(t *testing.T) { + target, _ := url.Parse("http://backend.internal:8080") + p := &ReverseProxy{forwardedProto: "auto"} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + t.Run("strips nb_session cookie", func(t *testing.T) { + pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") + pr.In.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: "jwt-token-here"}) + + rewrite(pr) + + cookies := pr.Out.Cookies() + for _, c := range cookies { + assert.NotEqual(t, auth.SessionCookieName, c.Name, + "proxy session cookie must not be forwarded to backend") + } + }) + + t.Run("preserves other cookies", func(t *testing.T) { + pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") + pr.In.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: "jwt-token"}) + pr.In.AddCookie(&http.Cookie{Name: "app_session", Value: "app-value"}) + pr.In.AddCookie(&http.Cookie{Name: "tracking", Value: "track-value"}) + + rewrite(pr) + + cookies := pr.Out.Cookies() + cookieNames := make([]string, 0, len(cookies)) + for _, c := range cookies { + cookieNames = append(cookieNames, c.Name) + } + assert.Contains(t, cookieNames, "app_session", "non-proxy cookies should be preserved") + assert.Contains(t, cookieNames, "tracking", "non-proxy cookies should be preserved") + assert.NotContains(t, cookieNames, auth.SessionCookieName, "proxy cookie must be stripped") + }) + + t.Run("handles request with no cookies", func(t *testing.T) { + pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") + + rewrite(pr) + + assert.Empty(t, pr.Out.Header.Get("Cookie")) + }) +} + +func TestRewriteFunc_SessionTokenQueryStripping(t *testing.T) { + target, _ := url.Parse("http://backend.internal:8080") + p := &ReverseProxy{forwardedProto: "auto"} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + t.Run("strips session_token query parameter", func(t *testing.T) { + pr := newProxyRequest(t, "http://example.com/callback?session_token=secret123&other=keep", "1.2.3.4:5000") + + rewrite(pr) + + assert.Empty(t, pr.Out.URL.Query().Get("session_token"), + "OIDC session token must be stripped from backend request") + assert.Equal(t, "keep", pr.Out.URL.Query().Get("other"), + "other query parameters must be preserved") + }) + + t.Run("preserves query when no session_token present", func(t *testing.T) { + pr := newProxyRequest(t, "http://example.com/api?foo=bar&baz=qux", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "bar", pr.Out.URL.Query().Get("foo")) + assert.Equal(t, "qux", pr.Out.URL.Query().Get("baz")) + }) +} + +func TestRewriteFunc_URLRewriting(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto"} + + t.Run("rewrites URL to target with path prefix", func(t *testing.T) { + target, _ := url.Parse("http://backend.internal:8080/app") + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "http://example.com/somepath", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "http", pr.Out.URL.Scheme) + assert.Equal(t, "backend.internal:8080", pr.Out.URL.Host) + assert.Equal(t, "/app/somepath", pr.Out.URL.Path, + "SetURL should join the target base path with the request path") + }) + + t.Run("strips matched path prefix to avoid duplication", func(t *testing.T) { + target, _ := url.Parse("https://backend.example.org:443/app") + rewrite := p.rewriteFunc(target, "/app", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "http://example.com/app", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "https", pr.Out.URL.Scheme) + assert.Equal(t, "backend.example.org:443", pr.Out.URL.Host) + assert.Equal(t, "/app/", pr.Out.URL.Path, + "matched path prefix should be stripped before joining with target path") + }) + + t.Run("strips matched prefix and preserves subpath", func(t *testing.T) { + target, _ := url.Parse("https://backend.example.org:443/app") + rewrite := p.rewriteFunc(target, "/app", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "http://example.com/app/article/123", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "/app/article/123", pr.Out.URL.Path, + "subpath after matched prefix should be preserved") + }) +} + +func TestExtractHostIP(t *testing.T) { + tests := []struct { + name string + remoteAddr string + expected netip.Addr + }{ + {"IPv4 with port", "192.168.1.1:12345", netip.MustParseAddr("192.168.1.1")}, + {"IPv6 with port", "[::1]:12345", netip.MustParseAddr("::1")}, + {"IPv6 full with port", "[2001:db8::1]:443", netip.MustParseAddr("2001:db8::1")}, + {"IPv4 without port fallback", "192.168.1.1", netip.MustParseAddr("192.168.1.1")}, + {"IPv6 without brackets fallback", "::1", netip.MustParseAddr("::1")}, + {"empty string fallback", "", netip.Addr{}}, + {"public IP", "203.0.113.50:9999", netip.MustParseAddr("203.0.113.50")}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, extractHostIP(tt.remoteAddr)) + }) + } +} + +func TestExtractForwardedPort(t *testing.T) { + tests := []struct { + name string + host string + resolvedProto string + expected string + }{ + {"explicit port in host", "example.com:8443", "https", "8443"}, + {"explicit port overrides proto default", "example.com:9090", "http", "9090"}, + {"no port defaults to 443 for https", "example.com", "https", "443"}, + {"no port defaults to 80 for http", "example.com", "http", "80"}, + {"IPv6 host with port", "[::1]:8080", "http", "8080"}, + {"IPv6 host without port", "::1", "https", "443"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, extractForwardedPort(tt.host, tt.resolvedProto)) + }) + } +} + +func TestRewriteFunc_TrustedProxy(t *testing.T) { + target, _ := url.Parse("http://backend.internal:8080") + trusted := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")} + + t.Run("appends to X-Forwarded-For", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") + pr.In.Header.Set("X-Forwarded-For", "203.0.113.50") + + rewrite(pr) + + assert.Equal(t, "203.0.113.50, 10.0.0.1", pr.Out.Header.Get("X-Forwarded-For")) + }) + + t.Run("preserves upstream X-Real-IP", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") + pr.In.Header.Set("X-Forwarded-For", "203.0.113.50") + pr.In.Header.Set("X-Real-IP", "203.0.113.50") + + rewrite(pr) + + assert.Equal(t, "203.0.113.50", pr.Out.Header.Get("X-Real-IP")) + }) + + t.Run("resolves X-Real-IP from XFF when not set by upstream", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") + pr.In.Header.Set("X-Forwarded-For", "203.0.113.50, 10.0.0.2") + + rewrite(pr) + + assert.Equal(t, "203.0.113.50", pr.Out.Header.Get("X-Real-IP"), + "should resolve real client through trusted chain") + }) + + t.Run("preserves upstream X-Forwarded-Host", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + pr := newProxyRequest(t, "http://proxy.internal/", "10.0.0.1:5000") + pr.In.Header.Set("X-Forwarded-Host", "original.example.com") + + rewrite(pr) + + assert.Equal(t, "original.example.com", pr.Out.Header.Get("X-Forwarded-Host")) + }) + + t.Run("preserves upstream X-Forwarded-Proto", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") + pr.In.Header.Set("X-Forwarded-Proto", "https") + + rewrite(pr) + + assert.Equal(t, "https", pr.Out.Header.Get("X-Forwarded-Proto")) + }) + + t.Run("preserves upstream X-Forwarded-Port", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") + pr.In.Header.Set("X-Forwarded-Port", "8443") + + rewrite(pr) + + assert.Equal(t, "8443", pr.Out.Header.Get("X-Forwarded-Port")) + }) + + t.Run("falls back to local proto when upstream does not set it", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "https", trustedProxies: trusted} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") + + rewrite(pr) + + assert.Equal(t, "https", pr.Out.Header.Get("X-Forwarded-Proto"), + "should use configured forwardedProto as fallback") + }) + + t.Run("sets X-Forwarded-Host from request when upstream does not set it", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") + + rewrite(pr) + + assert.Equal(t, "example.com", pr.Out.Header.Get("X-Forwarded-Host")) + }) + + t.Run("untrusted RemoteAddr strips headers even with trusted list", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999") + pr.In.Header.Set("X-Forwarded-For", "10.0.0.1, 172.16.0.1") + pr.In.Header.Set("X-Real-IP", "evil") + pr.In.Header.Set("X-Forwarded-Host", "evil.example.com") + pr.In.Header.Set("X-Forwarded-Proto", "https") + pr.In.Header.Set("X-Forwarded-Port", "9999") + + rewrite(pr) + + assert.Equal(t, "203.0.113.50", pr.Out.Header.Get("X-Forwarded-For"), + "untrusted: XFF must be replaced") + assert.Equal(t, "203.0.113.50", pr.Out.Header.Get("X-Real-IP"), + "untrusted: X-Real-IP must be replaced") + assert.Equal(t, "example.com", pr.Out.Header.Get("X-Forwarded-Host"), + "untrusted: host must be from direct connection") + assert.Equal(t, "http", pr.Out.Header.Get("X-Forwarded-Proto"), + "untrusted: proto must be locally resolved") + assert.Equal(t, "80", pr.Out.Header.Get("X-Forwarded-Port"), + "untrusted: port must be locally computed") + }) + + t.Run("empty trusted list behaves as untrusted", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto", trustedProxies: nil} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") + pr.In.Header.Set("X-Forwarded-For", "203.0.113.50") + + rewrite(pr) + + assert.Equal(t, "10.0.0.1", pr.Out.Header.Get("X-Forwarded-For"), + "nil trusted list: should strip and use RemoteAddr") + }) + + t.Run("XFF starts fresh when trusted proxy has no upstream XFF", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") + + rewrite(pr) + + assert.Equal(t, "10.0.0.1", pr.Out.Header.Get("X-Forwarded-For"), + "no upstream XFF: should set direct connection IP") + }) +} + +// TestRewriteFunc_PathForwarding verifies what path the backend actually +// receives given different configurations. This simulates the full pipeline: +// management builds a target URL (with matching prefix baked into the path), +// then the proxy strips the prefix and SetURL re-joins with the target path. +func TestRewriteFunc_PathForwarding(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto"} + + // Simulate what ToProtoMapping does: target URL includes the matching + // prefix as its path component, so the proxy strips-then-re-adds. + t.Run("path prefix baked into target URL is a no-op", func(t *testing.T) { + // Management builds: path="/heise", target="https://heise.de:443/heise" + target, _ := url.Parse("https://heise.de:443/heise") + rewrite := p.rewriteFunc(target, "/heise", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "http://external.test/heise", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "/heise/", pr.Out.URL.Path, + "backend sees /heise/ because prefix is stripped then re-added by SetURL") + }) + + t.Run("subpath under prefix also preserved", func(t *testing.T) { + target, _ := url.Parse("https://heise.de:443/heise") + rewrite := p.rewriteFunc(target, "/heise", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "http://external.test/heise/article/123", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "/heise/article/123", pr.Out.URL.Path, + "subpath is preserved on top of the re-added prefix") + }) + + // What the behavior WOULD be if target URL had no path (true stripping) + t.Run("target without path prefix gives true stripping", func(t *testing.T) { + target, _ := url.Parse("https://heise.de:443") + rewrite := p.rewriteFunc(target, "/heise", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "http://external.test/heise", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "/", pr.Out.URL.Path, + "without path in target URL, backend sees / (true prefix stripping)") + }) + + t.Run("target without path prefix strips and preserves subpath", func(t *testing.T) { + target, _ := url.Parse("https://heise.de:443") + rewrite := p.rewriteFunc(target, "/heise", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "http://external.test/heise/article/123", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "/article/123", pr.Out.URL.Path, + "without path in target URL, prefix is truly stripped") + }) + + // Root path "/" — no stripping expected + t.Run("root path forwards full request path unchanged", func(t *testing.T) { + target, _ := url.Parse("https://backend.example.com:443/") + rewrite := p.rewriteFunc(target, "/", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "http://external.test/heise", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "/heise", pr.Out.URL.Path, + "root path match must not strip anything") + }) +} + +func TestRewriteFunc_PreservePath(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto"} + target, _ := url.Parse("http://backend.internal:8080") + + t.Run("preserve keeps full request path", func(t *testing.T) { + rewrite := p.rewriteFunc(target, "/api", false, PathRewritePreserve, nil, nil) + pr := newProxyRequest(t, "http://example.com/api/users/123", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "/api/users/123", pr.Out.URL.Path, + "preserve should keep the full original request path") + }) + + t.Run("preserve with root matchedPath", func(t *testing.T) { + rewrite := p.rewriteFunc(target, "/", false, PathRewritePreserve, nil, nil) + pr := newProxyRequest(t, "http://example.com/anything", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "/anything", pr.Out.URL.Path) + }) +} + +func TestRewriteFunc_CustomHeaders(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto"} + target, _ := url.Parse("http://backend.internal:8080") + + t.Run("injects custom headers", func(t *testing.T) { + headers := map[string]string{ + "X-Custom-Auth": "token-abc", + "X-Env": "production", + } + rewrite := p.rewriteFunc(target, "/", false, PathRewriteDefault, headers, nil) + pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "token-abc", pr.Out.Header.Get("X-Custom-Auth")) + assert.Equal(t, "production", pr.Out.Header.Get("X-Env")) + }) + + t.Run("nil customHeaders is fine", func(t *testing.T) { + rewrite := p.rewriteFunc(target, "/", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "backend.internal:8080", pr.Out.Host) + }) + + t.Run("custom headers override existing request headers", func(t *testing.T) { + headers := map[string]string{"X-Override": "new-value"} + rewrite := p.rewriteFunc(target, "/", false, PathRewriteDefault, headers, nil) + pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") + pr.In.Header.Set("X-Override", "old-value") + + rewrite(pr) + + assert.Equal(t, "new-value", pr.Out.Header.Get("X-Override")) + }) +} + +func TestRewriteFunc_StripsAuthorizationHeader(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto"} + target, _ := url.Parse("http://backend.internal:8080") + + t.Run("strips incoming Authorization when no custom Authorization set", func(t *testing.T) { + rewrite := p.rewriteFunc(target, "/", false, PathRewriteDefault, nil, []string{"Authorization"}) + pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") + pr.In.Header.Set("Authorization", "Bearer proxy-token") + + rewrite(pr) + + assert.Empty(t, pr.Out.Header.Get("Authorization"), "Authorization should be stripped") + }) + + t.Run("custom Authorization replaces incoming", func(t *testing.T) { + headers := map[string]string{"Authorization": "Basic YmFja2VuZDpzZWNyZXQ="} + rewrite := p.rewriteFunc(target, "/", false, PathRewriteDefault, headers, []string{"Authorization"}) + pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") + pr.In.Header.Set("Authorization", "Bearer proxy-token") + + rewrite(pr) + + assert.Equal(t, "Basic YmFja2VuZDpzZWNyZXQ=", pr.Out.Header.Get("Authorization"), + "backend Authorization from custom headers should be set") + }) +} + +func TestRewriteFunc_PreservePathWithCustomHeaders(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto"} + target, _ := url.Parse("http://backend.internal:8080") + + rewrite := p.rewriteFunc(target, "/api", false, PathRewritePreserve, map[string]string{"X-Via": "proxy"}, nil) + pr := newProxyRequest(t, "http://example.com/api/deep/path", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "/api/deep/path", pr.Out.URL.Path, "preserve should keep the full original path") + assert.Equal(t, "proxy", pr.Out.Header.Get("X-Via"), "custom header should be set") +} + +func TestRewriteLocationFunc(t *testing.T) { + target, _ := url.Parse("http://backend.internal:8080") + newProxy := func(proto string) *ReverseProxy { return &ReverseProxy{forwardedProto: proto} } + newReq := func(rawURL string) *http.Request { + t.Helper() + r := httptest.NewRequest(http.MethodGet, rawURL, nil) + parsed, _ := url.Parse(rawURL) + r.Host = parsed.Host + return r + } + run := func(p *ReverseProxy, matchedPath string, inReq *http.Request, location string) (*http.Response, error) { + t.Helper() + modifyResp := p.rewriteLocationFunc(target, matchedPath, inReq) //nolint:bodyclose + resp := &http.Response{Header: http.Header{}} + if location != "" { + resp.Header.Set("Location", location) + } + err := modifyResp(resp) + return resp, err + } + + t.Run("rewrites Location pointing to backend", func(t *testing.T) { + resp, err := run(newProxy("https"), "", newReq("https://public.example.com/page"), //nolint:bodyclose + "http://backend.internal:8080/login") + + require.NoError(t, err) + assert.Equal(t, "https://public.example.com/login", resp.Header.Get("Location")) + }) + + t.Run("does not rewrite Location pointing to other host", func(t *testing.T) { + resp, err := run(newProxy("https"), "", newReq("https://public.example.com/"), //nolint:bodyclose + "https://other.example.com/path") + + require.NoError(t, err) + assert.Equal(t, "https://other.example.com/path", resp.Header.Get("Location")) + }) + + t.Run("does not rewrite relative Location", func(t *testing.T) { + resp, err := run(newProxy("https"), "", newReq("https://public.example.com/"), //nolint:bodyclose + "/dashboard") + + require.NoError(t, err) + assert.Equal(t, "/dashboard", resp.Header.Get("Location")) + }) + + t.Run("re-adds stripped path prefix", func(t *testing.T) { + resp, err := run(newProxy("https"), "/api", newReq("https://public.example.com/api/users"), //nolint:bodyclose + "http://backend.internal:8080/users") + + require.NoError(t, err) + assert.Equal(t, "https://public.example.com/api/users", resp.Header.Get("Location")) + }) + + t.Run("uses resolved proto for scheme", func(t *testing.T) { + resp, err := run(newProxy("auto"), "", newReq("http://public.example.com/"), //nolint:bodyclose + "http://backend.internal:8080/path") + + require.NoError(t, err) + assert.Equal(t, "http://public.example.com/path", resp.Header.Get("Location")) + }) + + t.Run("no-op when Location header is empty", func(t *testing.T) { + resp, err := run(newProxy("https"), "", newReq("https://public.example.com/"), "") //nolint:bodyclose + + require.NoError(t, err) + assert.Empty(t, resp.Header.Get("Location")) + }) + + t.Run("does not prepend root path prefix", func(t *testing.T) { + resp, err := run(newProxy("https"), "/", newReq("https://public.example.com/login"), //nolint:bodyclose + "http://backend.internal:8080/login") + + require.NoError(t, err) + assert.Equal(t, "https://public.example.com/login", resp.Header.Get("Location")) + }) + + // --- Edge cases: query parameters and fragments --- + + t.Run("preserves query parameters", func(t *testing.T) { + resp, err := run(newProxy("https"), "", newReq("https://public.example.com/"), //nolint:bodyclose + "http://backend.internal:8080/login?redirect=%2Fdashboard&lang=en") + + require.NoError(t, err) + assert.Equal(t, "https://public.example.com/login?redirect=%2Fdashboard&lang=en", resp.Header.Get("Location")) + }) + + t.Run("preserves fragment", func(t *testing.T) { + resp, err := run(newProxy("https"), "", newReq("https://public.example.com/"), //nolint:bodyclose + "http://backend.internal:8080/docs#section-2") + + require.NoError(t, err) + assert.Equal(t, "https://public.example.com/docs#section-2", resp.Header.Get("Location")) + }) + + t.Run("preserves query parameters and fragment together", func(t *testing.T) { + resp, err := run(newProxy("https"), "", newReq("https://public.example.com/"), //nolint:bodyclose + "http://backend.internal:8080/search?q=test&page=1#results") + + require.NoError(t, err) + assert.Equal(t, "https://public.example.com/search?q=test&page=1#results", resp.Header.Get("Location")) + }) + + t.Run("preserves query parameters with path prefix re-added", func(t *testing.T) { + resp, err := run(newProxy("https"), "/api", newReq("https://public.example.com/api/search"), //nolint:bodyclose + "http://backend.internal:8080/search?q=hello") + + require.NoError(t, err) + assert.Equal(t, "https://public.example.com/api/search?q=hello", resp.Header.Get("Location")) + }) + + // --- Edge cases: slash handling --- + + t.Run("no double slash when matchedPath has trailing slash", func(t *testing.T) { + resp, err := run(newProxy("https"), "/api/", newReq("https://public.example.com/api/users"), //nolint:bodyclose + "http://backend.internal:8080/users") + + require.NoError(t, err) + assert.Equal(t, "https://public.example.com/api/users", resp.Header.Get("Location")) + }) + + t.Run("backend redirect to root with path prefix", func(t *testing.T) { + resp, err := run(newProxy("https"), "/app", newReq("https://public.example.com/app/"), //nolint:bodyclose + "http://backend.internal:8080/") + + require.NoError(t, err) + assert.Equal(t, "https://public.example.com/app/", resp.Header.Get("Location")) + }) + + t.Run("backend redirect to root with trailing-slash path prefix", func(t *testing.T) { + resp, err := run(newProxy("https"), "/app/", newReq("https://public.example.com/app/"), //nolint:bodyclose + "http://backend.internal:8080/") + + require.NoError(t, err) + assert.Equal(t, "https://public.example.com/app/", resp.Header.Get("Location")) + }) + + t.Run("preserves trailing slash on redirect path", func(t *testing.T) { + resp, err := run(newProxy("https"), "", newReq("https://public.example.com/"), //nolint:bodyclose + "http://backend.internal:8080/path/") + + require.NoError(t, err) + assert.Equal(t, "https://public.example.com/path/", resp.Header.Get("Location")) + }) + + t.Run("backend redirect to bare root", func(t *testing.T) { + resp, err := run(newProxy("https"), "", newReq("https://public.example.com/page"), //nolint:bodyclose + "http://backend.internal:8080/") + + require.NoError(t, err) + assert.Equal(t, "https://public.example.com/", resp.Header.Get("Location")) + }) + + // --- Edge cases: host/port matching --- + + t.Run("does not rewrite when backend host matches but port differs", func(t *testing.T) { + resp, err := run(newProxy("https"), "", newReq("https://public.example.com/"), //nolint:bodyclose + "http://backend.internal:9090/other") + + require.NoError(t, err) + assert.Equal(t, "http://backend.internal:9090/other", resp.Header.Get("Location"), + "Different port means different host authority, must not rewrite") + }) + + t.Run("rewrites when redirect omits default port matching target", func(t *testing.T) { + // Target is backend.internal:8080, redirect is to backend.internal (no port). + // These are different authorities, so should NOT rewrite. + resp, err := run(newProxy("https"), "", newReq("https://public.example.com/"), //nolint:bodyclose + "http://backend.internal/path") + + require.NoError(t, err) + assert.Equal(t, "http://backend.internal/path", resp.Header.Get("Location"), + "backend.internal != backend.internal:8080, must not rewrite") + }) + + t.Run("rewrites when target has :443 but redirect omits it for https", func(t *testing.T) { + // Target: heise.de:443, redirect: https://heise.de/path (no :443 because it's default) + // Per RFC 3986, these are the same authority. + target443, _ := url.Parse("https://heise.de:443") + p := newProxy("https") + modifyResp := p.rewriteLocationFunc(target443, "", newReq("https://public.example.com/")) //nolint:bodyclose + resp := &http.Response{Header: http.Header{}} + resp.Header.Set("Location", "https://heise.de/path") + + err := modifyResp(resp) + + require.NoError(t, err) + assert.Equal(t, "https://public.example.com/path", resp.Header.Get("Location"), + "heise.de:443 and heise.de are the same for https") + }) + + t.Run("rewrites when target has :80 but redirect omits it for http", func(t *testing.T) { + target80, _ := url.Parse("http://backend.local:80") + p := newProxy("http") + modifyResp := p.rewriteLocationFunc(target80, "", newReq("http://public.example.com/")) //nolint:bodyclose + resp := &http.Response{Header: http.Header{}} + resp.Header.Set("Location", "http://backend.local/path") + + err := modifyResp(resp) + + require.NoError(t, err) + assert.Equal(t, "http://public.example.com/path", resp.Header.Get("Location"), + "backend.local:80 and backend.local are the same for http") + }) + + t.Run("rewrites when redirect has :443 but target omits it", func(t *testing.T) { + targetNoPort, _ := url.Parse("https://heise.de") + p := newProxy("https") + modifyResp := p.rewriteLocationFunc(targetNoPort, "", newReq("https://public.example.com/")) //nolint:bodyclose + resp := &http.Response{Header: http.Header{}} + resp.Header.Set("Location", "https://heise.de:443/path") + + err := modifyResp(resp) + + require.NoError(t, err) + assert.Equal(t, "https://public.example.com/path", resp.Header.Get("Location"), + "heise.de and heise.de:443 are the same for https") + }) + + t.Run("does not conflate non-default ports", func(t *testing.T) { + target8443, _ := url.Parse("https://backend.internal:8443") + p := newProxy("https") + modifyResp := p.rewriteLocationFunc(target8443, "", newReq("https://public.example.com/")) //nolint:bodyclose + resp := &http.Response{Header: http.Header{}} + resp.Header.Set("Location", "https://backend.internal/path") + + err := modifyResp(resp) + + require.NoError(t, err) + assert.Equal(t, "https://backend.internal/path", resp.Header.Get("Location"), + "backend.internal:8443 != backend.internal (port 443), must not rewrite") + }) + + // --- Edge cases: encoded paths --- + + t.Run("preserves percent-encoded path segments", func(t *testing.T) { + resp, err := run(newProxy("https"), "", newReq("https://public.example.com/"), //nolint:bodyclose + "http://backend.internal:8080/path%20with%20spaces/file%2Fname") + + require.NoError(t, err) + loc := resp.Header.Get("Location") + assert.Contains(t, loc, "public.example.com") + parsed, err := url.Parse(loc) + require.NoError(t, err) + assert.Equal(t, "/path with spaces/file/name", parsed.Path) + }) + + t.Run("preserves encoded query parameters with path prefix", func(t *testing.T) { + resp, err := run(newProxy("https"), "/v1", newReq("https://public.example.com/v1/"), //nolint:bodyclose + "http://backend.internal:8080/redirect?url=http%3A%2F%2Fexample.com") + + require.NoError(t, err) + assert.Equal(t, "https://public.example.com/v1/redirect?url=http%3A%2F%2Fexample.com", resp.Header.Get("Location")) + }) +} + +// newProxyRequest creates an httputil.ProxyRequest suitable for testing +// the Rewrite function. It simulates what httputil.ReverseProxy does internally: +// Out is a shallow clone of In with headers copied. +func newProxyRequest(t *testing.T, rawURL, remoteAddr string) *httputil.ProxyRequest { + t.Helper() + + parsed, err := url.Parse(rawURL) + require.NoError(t, err) + + in := httptest.NewRequest(http.MethodGet, rawURL, nil) + in.RemoteAddr = remoteAddr + in.Host = parsed.Host + + out := in.Clone(in.Context()) + out.Header = in.Header.Clone() + + return &httputil.ProxyRequest{In: in, Out: out} +} + +func TestClassifyProxyError(t *testing.T) { + tests := []struct { + name string + err error + wantTitle string + wantCode int + wantStatus web.ErrorStatus + }{ + { + name: "context deadline exceeded", + err: context.DeadlineExceeded, + wantTitle: "Request Timeout", + wantCode: http.StatusGatewayTimeout, + wantStatus: web.ErrorStatus{Proxy: true, Destination: false}, + }, + { + name: "wrapped deadline exceeded", + err: fmt.Errorf("dial: %w", context.DeadlineExceeded), + wantTitle: "Request Timeout", + wantCode: http.StatusGatewayTimeout, + wantStatus: web.ErrorStatus{Proxy: true, Destination: false}, + }, + { + name: "context canceled", + err: context.Canceled, + wantTitle: "Request Canceled", + wantCode: http.StatusBadGateway, + wantStatus: web.ErrorStatus{Proxy: true, Destination: false}, + }, + { + name: "no account ID", + err: roundtrip.ErrNoAccountID, + wantTitle: "Configuration Error", + wantCode: http.StatusInternalServerError, + wantStatus: web.ErrorStatus{Proxy: false, Destination: false}, + }, + { + name: "no peer connection", + err: fmt.Errorf("%w for account: abc", roundtrip.ErrNoPeerConnection), + wantTitle: "Proxy Not Connected", + wantCode: http.StatusBadGateway, + wantStatus: web.ErrorStatus{Proxy: false, Destination: false}, + }, + { + name: "client not started", + err: fmt.Errorf("%w: %w", roundtrip.ErrClientStartFailed, errors.New("engine init failed")), + wantTitle: "Proxy Not Connected", + wantCode: http.StatusBadGateway, + wantStatus: web.ErrorStatus{Proxy: false, Destination: false}, + }, + { + name: "syscall ECONNREFUSED via os.SyscallError", + err: &net.OpError{ + Op: "dial", + Net: "tcp", + Err: &os.SyscallError{Syscall: "connect", Err: syscall.ECONNREFUSED}, + }, + wantTitle: "Service Unavailable", + wantCode: http.StatusBadGateway, + wantStatus: web.ErrorStatus{Proxy: true, Destination: false}, + }, + { + name: "gvisor connection was refused", + err: &net.OpError{ + Op: "connect", + Net: "tcp", + Err: errors.New("connection was refused"), + }, + wantTitle: "Service Unavailable", + wantCode: http.StatusBadGateway, + wantStatus: web.ErrorStatus{Proxy: true, Destination: false}, + }, + { + name: "syscall EHOSTUNREACH via os.SyscallError", + err: &net.OpError{ + Op: "dial", + Net: "tcp", + Err: &os.SyscallError{Syscall: "connect", Err: syscall.EHOSTUNREACH}, + }, + wantTitle: "Peer Not Connected", + wantCode: http.StatusBadGateway, + wantStatus: web.ErrorStatus{Proxy: true, Destination: false}, + }, + { + name: "syscall ENETUNREACH via os.SyscallError", + err: &net.OpError{ + Op: "dial", + Net: "tcp", + Err: &os.SyscallError{Syscall: "connect", Err: syscall.ENETUNREACH}, + }, + wantTitle: "Peer Not Connected", + wantCode: http.StatusBadGateway, + wantStatus: web.ErrorStatus{Proxy: true, Destination: false}, + }, + { + name: "gvisor host is unreachable", + err: &net.OpError{ + Op: "connect", + Net: "tcp", + Err: errors.New("host is unreachable"), + }, + wantTitle: "Peer Not Connected", + wantCode: http.StatusBadGateway, + wantStatus: web.ErrorStatus{Proxy: true, Destination: false}, + }, + { + name: "gvisor network is unreachable", + err: &net.OpError{ + Op: "connect", + Net: "tcp", + Err: errors.New("network is unreachable"), + }, + wantTitle: "Peer Not Connected", + wantCode: http.StatusBadGateway, + wantStatus: web.ErrorStatus{Proxy: true, Destination: false}, + }, + { + name: "standard no route to host", + err: &net.OpError{ + Op: "dial", + Net: "tcp", + Err: &os.SyscallError{Syscall: "connect", Err: syscall.EHOSTUNREACH}, + }, + wantTitle: "Peer Not Connected", + wantCode: http.StatusBadGateway, + wantStatus: web.ErrorStatus{Proxy: true, Destination: false}, + }, + { + name: "unknown error falls to default", + err: errors.New("something unexpected"), + wantTitle: "Connection Error", + wantCode: http.StatusBadGateway, + wantStatus: web.ErrorStatus{Proxy: true, Destination: false}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + title, _, code, status := classifyProxyError(tt.err) + assert.Equal(t, tt.wantTitle, title, "title") + assert.Equal(t, tt.wantCode, code, "status code") + assert.Equal(t, tt.wantStatus, status, "component status") + }) + } +} diff --git a/proxy/internal/proxy/servicemapping.go b/proxy/internal/proxy/servicemapping.go new file mode 100644 index 000000000..fe470cf01 --- /dev/null +++ b/proxy/internal/proxy/servicemapping.go @@ -0,0 +1,123 @@ +package proxy + +import ( + "net" + "net/http" + "net/url" + "sort" + "strings" + "time" + + "github.com/netbirdio/netbird/proxy/internal/types" +) + +// PathRewriteMode controls how the request path is rewritten before forwarding. +type PathRewriteMode int + +const ( + // PathRewriteDefault strips the matched prefix and joins with the target path. + PathRewriteDefault PathRewriteMode = iota + // PathRewritePreserve keeps the full original request path as-is. + PathRewritePreserve +) + +// PathTarget holds a backend URL and per-target behavioral options. +type PathTarget struct { + URL *url.URL + SkipTLSVerify bool + RequestTimeout time.Duration + PathRewrite PathRewriteMode + CustomHeaders map[string]string +} + +// Mapping describes how a domain is routed by the HTTP reverse proxy. +type Mapping struct { + ID types.ServiceID + AccountID types.AccountID + Host string + Paths map[string]*PathTarget + PassHostHeader bool + RewriteRedirects bool + // StripAuthHeaders are header names used for header-based auth. + // These headers are stripped from requests before forwarding. + StripAuthHeaders []string + // sortedPaths caches the paths sorted by length (longest first). + sortedPaths []string +} + +type targetResult struct { + target *PathTarget + matchedPath string + serviceID types.ServiceID + accountID types.AccountID + passHostHeader bool + rewriteRedirects bool + stripAuthHeaders []string +} + +func (p *ReverseProxy) findTargetForRequest(req *http.Request) (targetResult, bool) { + p.mappingsMux.RLock() + defer p.mappingsMux.RUnlock() + + // Strip port from host if present (e.g., "external.test:8443" -> "external.test") + host := req.Host + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + + m, exists := p.mappings[host] + if !exists { + p.logger.Debugf("no mapping found for host: %s", host) + return targetResult{}, false + } + + for _, path := range m.sortedPaths { + if strings.HasPrefix(req.URL.Path, path) { + pt := m.Paths[path] + if pt == nil || pt.URL == nil { + p.logger.Warnf("invalid mapping for host: %s, path: %s (nil target)", host, path) + continue + } + p.logger.Debugf("matched host: %s, path: %s -> %s", host, path, pt.URL) + return targetResult{ + target: pt, + matchedPath: path, + serviceID: m.ID, + accountID: m.AccountID, + passHostHeader: m.PassHostHeader, + rewriteRedirects: m.RewriteRedirects, + stripAuthHeaders: m.StripAuthHeaders, + }, true + } + } + p.logger.Debugf("no path match for host: %s, path: %s", host, req.URL.Path) + return targetResult{}, false +} + +// AddMapping registers a host-to-backend mapping for the reverse proxy. +func (p *ReverseProxy) AddMapping(m Mapping) { + // Sort paths longest-first to match the most specific route first. + paths := make([]string, 0, len(m.Paths)) + for path := range m.Paths { + paths = append(paths, path) + } + sort.Slice(paths, func(i, j int) bool { + return len(paths[i]) > len(paths[j]) + }) + m.sortedPaths = paths + + p.mappingsMux.Lock() + defer p.mappingsMux.Unlock() + p.mappings[m.Host] = m +} + +// RemoveMapping removes the mapping for the given host and reports whether it existed. +func (p *ReverseProxy) RemoveMapping(m Mapping) bool { + p.mappingsMux.Lock() + defer p.mappingsMux.Unlock() + if _, ok := p.mappings[m.Host]; !ok { + return false + } + delete(p.mappings, m.Host) + return true +} diff --git a/proxy/internal/proxy/trustedproxy.go b/proxy/internal/proxy/trustedproxy.go new file mode 100644 index 000000000..0fe693f90 --- /dev/null +++ b/proxy/internal/proxy/trustedproxy.go @@ -0,0 +1,81 @@ +package proxy + +import ( + "net/netip" + "strings" +) + +// IsTrustedProxy checks if the given IP string falls within any of the trusted prefixes. +func IsTrustedProxy(ipStr string, trusted []netip.Prefix) bool { + addr, err := netip.ParseAddr(ipStr) + if err != nil || len(trusted) == 0 { + return false + } + return isTrustedAddr(addr.Unmap(), trusted) +} + +// ResolveClientIP extracts the real client IP from X-Forwarded-For using the trusted proxy list. +// It walks the XFF chain right-to-left, skipping IPs that match trusted prefixes. +// The first untrusted IP is the real client. +// +// If the trusted list is empty or remoteAddr is not trusted, it returns the +// remoteAddr IP directly (ignoring any forwarding headers). +func ResolveClientIP(remoteAddr, xff string, trusted []netip.Prefix) netip.Addr { + remoteIP := extractHostIP(remoteAddr) + + if len(trusted) == 0 || !isTrustedAddr(remoteIP, trusted) { + return remoteIP + } + + if xff == "" { + return remoteIP + } + + parts := strings.Split(xff, ",") + for i := len(parts) - 1; i >= 0; i-- { + ip := strings.TrimSpace(parts[i]) + if ip == "" { + continue + } + addr, err := netip.ParseAddr(ip) + if err != nil { + continue + } + addr = addr.Unmap() + if !isTrustedAddr(addr, trusted) { + return addr + } + } + + // All IPs in XFF are trusted; return the leftmost as best guess. + if first := strings.TrimSpace(parts[0]); first != "" { + if addr, err := netip.ParseAddr(first); err == nil { + return addr.Unmap() + } + } + return remoteIP +} + +// extractHostIP parses the IP from a host:port string and returns it unmapped. +func extractHostIP(hostPort string) netip.Addr { + if ap, err := netip.ParseAddrPort(hostPort); err == nil { + return ap.Addr().Unmap() + } + if addr, err := netip.ParseAddr(hostPort); err == nil { + return addr.Unmap() + } + return netip.Addr{} +} + +// isTrustedAddr checks if the given address falls within any of the trusted prefixes. +func isTrustedAddr(addr netip.Addr, trusted []netip.Prefix) bool { + if !addr.IsValid() { + return false + } + for _, prefix := range trusted { + if prefix.Contains(addr) { + return true + } + } + return false +} diff --git a/proxy/internal/proxy/trustedproxy_test.go b/proxy/internal/proxy/trustedproxy_test.go new file mode 100644 index 000000000..35ed1f5c2 --- /dev/null +++ b/proxy/internal/proxy/trustedproxy_test.go @@ -0,0 +1,129 @@ +package proxy + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsTrustedProxy(t *testing.T) { + trusted := []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("192.168.1.0/24"), + netip.MustParsePrefix("fd00::/8"), + } + + tests := []struct { + name string + ip string + trusted []netip.Prefix + want bool + }{ + {"empty trusted list", "10.0.0.1", nil, false}, + {"IP within /8 prefix", "10.1.2.3", trusted, true}, + {"IP within /24 prefix", "192.168.1.100", trusted, true}, + {"IP outside all prefixes", "203.0.113.50", trusted, false}, + {"boundary IP just outside prefix", "192.168.2.1", trusted, false}, + {"unparsable IP", "not-an-ip", trusted, false}, + {"IPv6 in trusted range", "fd00::1", trusted, true}, + {"IPv6 outside range", "2001:db8::1", trusted, false}, + {"empty string", "", trusted, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, IsTrustedProxy(tt.ip, tt.trusted)) + }) + } +} + +func TestResolveClientIP(t *testing.T) { + trusted := []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("172.16.0.0/12"), + } + + tests := []struct { + name string + remoteAddr string + xff string + trusted []netip.Prefix + want netip.Addr + }{ + { + name: "empty trusted list returns RemoteAddr", + remoteAddr: "203.0.113.50:9999", + xff: "1.2.3.4", + trusted: nil, + want: netip.MustParseAddr("203.0.113.50"), + }, + { + name: "untrusted RemoteAddr ignores XFF", + remoteAddr: "203.0.113.50:9999", + xff: "1.2.3.4, 10.0.0.1", + trusted: trusted, + want: netip.MustParseAddr("203.0.113.50"), + }, + { + name: "trusted RemoteAddr with single client in XFF", + remoteAddr: "10.0.0.1:5000", + xff: "203.0.113.50", + trusted: trusted, + want: netip.MustParseAddr("203.0.113.50"), + }, + { + name: "trusted RemoteAddr walks past trusted entries in XFF", + remoteAddr: "10.0.0.1:5000", + xff: "203.0.113.50, 10.0.0.2, 172.16.0.5", + trusted: trusted, + want: netip.MustParseAddr("203.0.113.50"), + }, + { + name: "trusted RemoteAddr with empty XFF falls back to RemoteAddr", + remoteAddr: "10.0.0.1:5000", + xff: "", + trusted: trusted, + want: netip.MustParseAddr("10.0.0.1"), + }, + { + name: "all XFF IPs trusted returns leftmost", + remoteAddr: "10.0.0.1:5000", + xff: "10.0.0.2, 172.16.0.1, 10.0.0.3", + trusted: trusted, + want: netip.MustParseAddr("10.0.0.2"), + }, + { + name: "XFF with whitespace", + remoteAddr: "10.0.0.1:5000", + xff: " 203.0.113.50 , 10.0.0.2 ", + trusted: trusted, + want: netip.MustParseAddr("203.0.113.50"), + }, + { + name: "XFF with empty segments", + remoteAddr: "10.0.0.1:5000", + xff: "203.0.113.50,,10.0.0.2", + trusted: trusted, + want: netip.MustParseAddr("203.0.113.50"), + }, + { + name: "multi-hop with mixed trust", + remoteAddr: "10.0.0.1:5000", + xff: "8.8.8.8, 203.0.113.50, 172.16.0.1", + trusted: trusted, + want: netip.MustParseAddr("203.0.113.50"), + }, + { + name: "RemoteAddr without port", + remoteAddr: "10.0.0.1", + xff: "203.0.113.50", + trusted: trusted, + want: netip.MustParseAddr("203.0.113.50"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, ResolveClientIP(tt.remoteAddr, tt.xff, tt.trusted)) + }) + } +} diff --git a/proxy/internal/responsewriter/responsewriter.go b/proxy/internal/responsewriter/responsewriter.go new file mode 100644 index 000000000..b8fc95f2d --- /dev/null +++ b/proxy/internal/responsewriter/responsewriter.go @@ -0,0 +1,53 @@ +package responsewriter + +import ( + "bufio" + "net" + "net/http" +) + +// PassthroughWriter wraps an http.ResponseWriter and preserves optional +// interfaces like Hijacker, Flusher, and Pusher by delegating to the underlying +// ResponseWriter if it supports them. +// +// This is the standard pattern for Go middleware that needs to wrap ResponseWriter +// while maintaining support for protocol upgrades (WebSocket), streaming (Flusher), +// and HTTP/2 server push. +type PassthroughWriter struct { + http.ResponseWriter +} + +// New creates a new wrapper around the given ResponseWriter. +func New(w http.ResponseWriter) *PassthroughWriter { + return &PassthroughWriter{ResponseWriter: w} +} + +// Hijack implements http.Hijacker interface if the underlying ResponseWriter supports it. +// This is required for WebSocket connections and other protocol upgrades. +func (w *PassthroughWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + if hijacker, ok := w.ResponseWriter.(http.Hijacker); ok { + return hijacker.Hijack() + } + return nil, nil, http.ErrNotSupported +} + +// Flush implements http.Flusher interface if the underlying ResponseWriter supports it. +func (w *PassthroughWriter) Flush() { + if flusher, ok := w.ResponseWriter.(http.Flusher); ok { + flusher.Flush() + } +} + +// Push implements http.Pusher interface if the underlying ResponseWriter supports it. +func (w *PassthroughWriter) Push(target string, opts *http.PushOptions) error { + if pusher, ok := w.ResponseWriter.(http.Pusher); ok { + return pusher.Push(target, opts) + } + return http.ErrNotSupported +} + +// Unwrap returns the underlying ResponseWriter. +// This is required for http.ResponseController (Go 1.20+) to work correctly. +func (w *PassthroughWriter) Unwrap() http.ResponseWriter { + return w.ResponseWriter +} diff --git a/proxy/internal/restrict/restrict.go b/proxy/internal/restrict/restrict.go new file mode 100644 index 000000000..a0d99ce93 --- /dev/null +++ b/proxy/internal/restrict/restrict.go @@ -0,0 +1,183 @@ +// Package restrict provides connection-level access control based on +// IP CIDR ranges and geolocation (country codes). +package restrict + +import ( + "net/netip" + "slices" + "strings" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/proxy/internal/geolocation" +) + +// GeoResolver resolves an IP address to geographic information. +type GeoResolver interface { + LookupAddr(addr netip.Addr) geolocation.Result + Available() bool +} + +// Filter evaluates IP restrictions. CIDR checks are performed first +// (cheap), followed by country lookups (more expensive) only when needed. +type Filter struct { + AllowedCIDRs []netip.Prefix + BlockedCIDRs []netip.Prefix + AllowedCountries []string + BlockedCountries []string +} + +// ParseFilter builds a Filter from the raw string slices. Returns nil +// if all slices are empty. +func ParseFilter(allowedCIDRs, blockedCIDRs, allowedCountries, blockedCountries []string) *Filter { + if len(allowedCIDRs) == 0 && len(blockedCIDRs) == 0 && + len(allowedCountries) == 0 && len(blockedCountries) == 0 { + return nil + } + + f := &Filter{ + AllowedCountries: normalizeCountryCodes(allowedCountries), + BlockedCountries: normalizeCountryCodes(blockedCountries), + } + for _, cidr := range allowedCIDRs { + prefix, err := netip.ParsePrefix(cidr) + if err != nil { + log.Warnf("skip invalid allowed CIDR %q: %v", cidr, err) + continue + } + f.AllowedCIDRs = append(f.AllowedCIDRs, prefix.Masked()) + } + for _, cidr := range blockedCIDRs { + prefix, err := netip.ParsePrefix(cidr) + if err != nil { + log.Warnf("skip invalid blocked CIDR %q: %v", cidr, err) + continue + } + f.BlockedCIDRs = append(f.BlockedCIDRs, prefix.Masked()) + } + return f +} + +func normalizeCountryCodes(codes []string) []string { + if len(codes) == 0 { + return nil + } + out := make([]string, len(codes)) + for i, c := range codes { + out[i] = strings.ToUpper(c) + } + return out +} + +// Verdict is the result of an access check. +type Verdict int + +const ( + // Allow indicates the address passed all checks. + Allow Verdict = iota + // DenyCIDR indicates the address was blocked by a CIDR rule. + DenyCIDR + // DenyCountry indicates the address was blocked by a country rule. + DenyCountry + // DenyGeoUnavailable indicates that country restrictions are configured + // but the geo lookup is unavailable. + DenyGeoUnavailable +) + +// String returns the deny reason string matching the HTTP auth mechanism names. +func (v Verdict) String() string { + switch v { + case Allow: + return "allow" + case DenyCIDR: + return "ip_restricted" + case DenyCountry: + return "country_restricted" + case DenyGeoUnavailable: + return "geo_unavailable" + default: + return "unknown" + } +} + +// Check evaluates whether addr is permitted. CIDR rules are evaluated +// first because they are O(n) prefix comparisons. Country rules run +// only when CIDR checks pass and require a geo lookup. +func (f *Filter) Check(addr netip.Addr, geo GeoResolver) Verdict { + if f == nil { + return Allow + } + + // Normalize v4-mapped-v6 (e.g. ::ffff:10.1.2.3) to plain v4 so that + // IPv4 CIDR rules match regardless of how the address was received. + addr = addr.Unmap() + + if v := f.checkCIDR(addr); v != Allow { + return v + } + return f.checkCountry(addr, geo) +} + +func (f *Filter) checkCIDR(addr netip.Addr) Verdict { + if len(f.AllowedCIDRs) > 0 { + allowed := false + for _, prefix := range f.AllowedCIDRs { + if prefix.Contains(addr) { + allowed = true + break + } + } + if !allowed { + return DenyCIDR + } + } + + for _, prefix := range f.BlockedCIDRs { + if prefix.Contains(addr) { + return DenyCIDR + } + } + return Allow +} + +func (f *Filter) checkCountry(addr netip.Addr, geo GeoResolver) Verdict { + if len(f.AllowedCountries) == 0 && len(f.BlockedCountries) == 0 { + return Allow + } + + if geo == nil || !geo.Available() { + return DenyGeoUnavailable + } + + result := geo.LookupAddr(addr) + if result.CountryCode == "" { + // Unknown country: deny if an allowlist is active, allow otherwise. + // Blocklists are best-effort: unknown countries pass through since + // the default policy is allow. + if len(f.AllowedCountries) > 0 { + return DenyCountry + } + return Allow + } + + if len(f.AllowedCountries) > 0 { + if !slices.Contains(f.AllowedCountries, result.CountryCode) { + return DenyCountry + } + } + + if slices.Contains(f.BlockedCountries, result.CountryCode) { + return DenyCountry + } + + return Allow +} + +// HasRestrictions returns true if any restriction rules are configured. +func (f *Filter) HasRestrictions() bool { + if f == nil { + return false + } + return len(f.AllowedCIDRs) > 0 || len(f.BlockedCIDRs) > 0 || + len(f.AllowedCountries) > 0 || len(f.BlockedCountries) > 0 +} diff --git a/proxy/internal/restrict/restrict_test.go b/proxy/internal/restrict/restrict_test.go new file mode 100644 index 000000000..17a5848d8 --- /dev/null +++ b/proxy/internal/restrict/restrict_test.go @@ -0,0 +1,278 @@ +package restrict + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/proxy/internal/geolocation" +) + +type mockGeo struct { + countries map[string]string +} + +func (m *mockGeo) LookupAddr(addr netip.Addr) geolocation.Result { + return geolocation.Result{CountryCode: m.countries[addr.String()]} +} + +func (m *mockGeo) Available() bool { return true } + +func newMockGeo(entries map[string]string) *mockGeo { + return &mockGeo{countries: entries} +} + +func TestFilter_Check_NilFilter(t *testing.T) { + var f *Filter + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("1.2.3.4"), nil)) +} + +func TestFilter_Check_AllowedCIDR(t *testing.T) { + f := ParseFilter([]string{"10.0.0.0/8"}, nil, nil, nil) + + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("10.1.2.3"), nil)) + assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("192.168.1.1"), nil)) +} + +func TestFilter_Check_BlockedCIDR(t *testing.T) { + f := ParseFilter(nil, []string{"10.0.0.0/8"}, nil, nil) + + assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("10.1.2.3"), nil)) + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("192.168.1.1"), nil)) +} + +func TestFilter_Check_AllowedAndBlockedCIDR(t *testing.T) { + f := ParseFilter([]string{"10.0.0.0/8"}, []string{"10.1.0.0/16"}, nil, nil) + + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("10.2.3.4"), nil), "allowed by allowlist, not in blocklist") + assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("10.1.2.3"), nil), "allowed by allowlist but in blocklist") + assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("192.168.1.1"), nil), "not in allowlist") +} + +func TestFilter_Check_AllowedCountry(t *testing.T) { + geo := newMockGeo(map[string]string{ + "1.1.1.1": "US", + "2.2.2.2": "DE", + "3.3.3.3": "CN", + }) + f := ParseFilter(nil, nil, []string{"US", "DE"}, nil) + + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("1.1.1.1"), geo), "US in allowlist") + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("2.2.2.2"), geo), "DE in allowlist") + assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("3.3.3.3"), geo), "CN not in allowlist") +} + +func TestFilter_Check_BlockedCountry(t *testing.T) { + geo := newMockGeo(map[string]string{ + "1.1.1.1": "CN", + "2.2.2.2": "RU", + "3.3.3.3": "US", + }) + f := ParseFilter(nil, nil, nil, []string{"CN", "RU"}) + + assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("1.1.1.1"), geo), "CN in blocklist") + assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("2.2.2.2"), geo), "RU in blocklist") + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("3.3.3.3"), geo), "US not in blocklist") +} + +func TestFilter_Check_AllowedAndBlockedCountry(t *testing.T) { + geo := newMockGeo(map[string]string{ + "1.1.1.1": "US", + "2.2.2.2": "DE", + "3.3.3.3": "CN", + }) + // Allow US and DE, but block DE explicitly. + f := ParseFilter(nil, nil, []string{"US", "DE"}, []string{"DE"}) + + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("1.1.1.1"), geo), "US allowed and not blocked") + assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("2.2.2.2"), geo), "DE allowed but also blocked, block wins") + assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("3.3.3.3"), geo), "CN not in allowlist") +} + +func TestFilter_Check_UnknownCountryWithAllowlist(t *testing.T) { + geo := newMockGeo(map[string]string{ + "1.1.1.1": "US", + }) + f := ParseFilter(nil, nil, []string{"US"}, nil) + + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("1.1.1.1"), geo), "known US in allowlist") + assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("9.9.9.9"), geo), "unknown country denied when allowlist is active") +} + +func TestFilter_Check_UnknownCountryWithBlocklistOnly(t *testing.T) { + geo := newMockGeo(map[string]string{ + "1.1.1.1": "CN", + }) + f := ParseFilter(nil, nil, nil, []string{"CN"}) + + assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("1.1.1.1"), geo), "known CN in blocklist") + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("9.9.9.9"), geo), "unknown country allowed when only blocklist is active") +} + +func TestFilter_Check_CountryWithoutGeo(t *testing.T) { + f := ParseFilter(nil, nil, []string{"US"}, nil) + assert.Equal(t, DenyGeoUnavailable, f.Check(netip.MustParseAddr("1.2.3.4"), nil), "nil geo with country allowlist") +} + +func TestFilter_Check_CountryBlocklistWithoutGeo(t *testing.T) { + f := ParseFilter(nil, nil, nil, []string{"CN"}) + assert.Equal(t, DenyGeoUnavailable, f.Check(netip.MustParseAddr("1.2.3.4"), nil), "nil geo with country blocklist") +} + +func TestFilter_Check_GeoUnavailable(t *testing.T) { + geo := &unavailableGeo{} + + f := ParseFilter(nil, nil, []string{"US"}, nil) + assert.Equal(t, DenyGeoUnavailable, f.Check(netip.MustParseAddr("1.2.3.4"), geo), "unavailable geo with country allowlist") + + f2 := ParseFilter(nil, nil, nil, []string{"CN"}) + assert.Equal(t, DenyGeoUnavailable, f2.Check(netip.MustParseAddr("1.2.3.4"), geo), "unavailable geo with country blocklist") +} + +func TestFilter_Check_CIDROnlySkipsGeo(t *testing.T) { + f := ParseFilter([]string{"10.0.0.0/8"}, nil, nil, nil) + + // CIDR-only filter should never touch geo, so nil geo is fine. + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("10.1.2.3"), nil)) + assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("192.168.1.1"), nil)) +} + +func TestFilter_Check_CIDRAllowThenCountryBlock(t *testing.T) { + geo := newMockGeo(map[string]string{ + "10.1.2.3": "CN", + "10.2.3.4": "US", + }) + f := ParseFilter([]string{"10.0.0.0/8"}, nil, nil, []string{"CN"}) + + assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("10.1.2.3"), geo), "CIDR allowed but country blocked") + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("10.2.3.4"), geo), "CIDR allowed and country not blocked") + assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("192.168.1.1"), geo), "CIDR denied before country check") +} + +func TestParseFilter_Empty(t *testing.T) { + f := ParseFilter(nil, nil, nil, nil) + assert.Nil(t, f) +} + +func TestParseFilter_InvalidCIDR(t *testing.T) { + f := ParseFilter([]string{"invalid", "10.0.0.0/8"}, nil, nil, nil) + + assert.NotNil(t, f) + assert.Len(t, f.AllowedCIDRs, 1, "invalid CIDR should be skipped") + assert.Equal(t, netip.MustParsePrefix("10.0.0.0/8"), f.AllowedCIDRs[0]) +} + +func TestFilter_HasRestrictions(t *testing.T) { + assert.False(t, (*Filter)(nil).HasRestrictions()) + assert.False(t, (&Filter{}).HasRestrictions()) + assert.True(t, ParseFilter([]string{"10.0.0.0/8"}, nil, nil, nil).HasRestrictions()) + assert.True(t, ParseFilter(nil, nil, []string{"US"}, nil).HasRestrictions()) +} + +func TestFilter_Check_IPv6CIDR(t *testing.T) { + f := ParseFilter([]string{"2001:db8::/32"}, nil, nil, nil) + + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("2001:db8::1"), nil), "v6 addr in v6 allowlist") + assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("2001:db9::1"), nil), "v6 addr not in v6 allowlist") + assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("10.1.2.3"), nil), "v4 addr not in v6 allowlist") +} + +func TestFilter_Check_IPv4MappedIPv6(t *testing.T) { + f := ParseFilter([]string{"10.0.0.0/8"}, nil, nil, nil) + + // A v4-mapped-v6 address like ::ffff:10.1.2.3 must match a v4 CIDR. + v4mapped := netip.MustParseAddr("::ffff:10.1.2.3") + assert.True(t, v4mapped.Is4In6(), "precondition: address is v4-in-v6") + assert.Equal(t, Allow, f.Check(v4mapped, nil), "v4-mapped-v6 must match v4 CIDR after Unmap") + + v4mappedOutside := netip.MustParseAddr("::ffff:192.168.1.1") + assert.Equal(t, DenyCIDR, f.Check(v4mappedOutside, nil), "v4-mapped-v6 outside v4 CIDR") +} + +func TestFilter_Check_MixedV4V6CIDRs(t *testing.T) { + f := ParseFilter([]string{"10.0.0.0/8", "2001:db8::/32"}, nil, nil, nil) + + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("10.1.2.3"), nil), "v4 in v4 CIDR") + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("2001:db8::1"), nil), "v6 in v6 CIDR") + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("::ffff:10.1.2.3"), nil), "v4-mapped matches v4 CIDR") + assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("192.168.1.1"), nil), "v4 not in either CIDR") + assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("fe80::1"), nil), "v6 not in either CIDR") +} + +func TestParseFilter_CanonicalizesNonMaskedCIDR(t *testing.T) { + // 1.1.1.1/24 has host bits set; ParseFilter should canonicalize to 1.1.1.0/24. + f := ParseFilter([]string{"1.1.1.1/24"}, nil, nil, nil) + assert.Equal(t, netip.MustParsePrefix("1.1.1.0/24"), f.AllowedCIDRs[0]) + + // Verify it still matches correctly. + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("1.1.1.100"), nil)) + assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("1.1.2.1"), nil)) +} + +func TestFilter_Check_CountryCodeCaseInsensitive(t *testing.T) { + geo := newMockGeo(map[string]string{ + "1.1.1.1": "US", + "2.2.2.2": "DE", + "3.3.3.3": "CN", + }) + + tests := []struct { + name string + allowedCountries []string + blockedCountries []string + addr string + want Verdict + }{ + { + name: "lowercase allowlist matches uppercase MaxMind code", + allowedCountries: []string{"us", "de"}, + addr: "1.1.1.1", + want: Allow, + }, + { + name: "mixed-case allowlist matches", + allowedCountries: []string{"Us", "dE"}, + addr: "2.2.2.2", + want: Allow, + }, + { + name: "lowercase allowlist rejects non-matching country", + allowedCountries: []string{"us", "de"}, + addr: "3.3.3.3", + want: DenyCountry, + }, + { + name: "lowercase blocklist blocks matching country", + blockedCountries: []string{"cn"}, + addr: "3.3.3.3", + want: DenyCountry, + }, + { + name: "mixed-case blocklist blocks matching country", + blockedCountries: []string{"Cn"}, + addr: "3.3.3.3", + want: DenyCountry, + }, + { + name: "lowercase blocklist does not block non-matching country", + blockedCountries: []string{"cn"}, + addr: "1.1.1.1", + want: Allow, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + f := ParseFilter(nil, nil, tc.allowedCountries, tc.blockedCountries) + got := f.Check(netip.MustParseAddr(tc.addr), geo) + assert.Equal(t, tc.want, got) + }) + } +} + +// unavailableGeo simulates a GeoResolver whose database is not loaded. +type unavailableGeo struct{} + +func (u *unavailableGeo) LookupAddr(_ netip.Addr) geolocation.Result { return geolocation.Result{} } +func (u *unavailableGeo) Available() bool { return false } diff --git a/proxy/internal/roundtrip/context_test.go b/proxy/internal/roundtrip/context_test.go new file mode 100644 index 000000000..c4e8267f8 --- /dev/null +++ b/proxy/internal/roundtrip/context_test.go @@ -0,0 +1,32 @@ +package roundtrip + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/proxy/internal/types" +) + +func TestAccountIDContext(t *testing.T) { + t.Run("returns empty when missing", func(t *testing.T) { + assert.Equal(t, types.AccountID(""), AccountIDFromContext(context.Background())) + }) + + t.Run("round-trips value", func(t *testing.T) { + ctx := WithAccountID(context.Background(), "acc-123") + assert.Equal(t, types.AccountID("acc-123"), AccountIDFromContext(ctx)) + }) +} + +func TestSkipTLSVerifyContext(t *testing.T) { + t.Run("false by default", func(t *testing.T) { + assert.False(t, skipTLSVerifyFromContext(context.Background())) + }) + + t.Run("true when set", func(t *testing.T) { + ctx := WithSkipTLSVerify(context.Background()) + assert.True(t, skipTLSVerifyFromContext(ctx)) + }) +} diff --git a/proxy/internal/roundtrip/netbird.go b/proxy/internal/roundtrip/netbird.go new file mode 100644 index 000000000..e38e3dc4e --- /dev/null +++ b/proxy/internal/roundtrip/netbird.go @@ -0,0 +1,635 @@ +package roundtrip + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "net/http" + "sync" + "time" + + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + "golang.org/x/exp/maps" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + grpcstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/embed" + nberrors "github.com/netbirdio/netbird/client/errors" + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/util" +) + +const deviceNamePrefix = "ingress-proxy-" + +// backendKey identifies a backend by its host:port from the target URL. +type backendKey string + +// ServiceKey uniquely identifies a service (HTTP reverse proxy or L4 service) +// that holds a reference to an embedded NetBird client. Callers should use the +// DomainServiceKey and L4ServiceKey constructors to avoid namespace collisions. +type ServiceKey string + +// DomainServiceKey returns a ServiceKey for an HTTP/TLS domain-based service. +func DomainServiceKey(domain string) ServiceKey { + return ServiceKey("domain:" + domain) +} + +// L4ServiceKey returns a ServiceKey for an L4 service (TCP/UDP). +func L4ServiceKey(id types.ServiceID) ServiceKey { + return ServiceKey("l4:" + id) +} + +var ( + // ErrNoAccountID is returned when a request context is missing the account ID. + ErrNoAccountID = errors.New("no account ID in request context") + // ErrNoPeerConnection is returned when no embedded client exists for the account. + ErrNoPeerConnection = errors.New("no peer connection found") + // ErrClientStartFailed is returned when the embedded client fails to start. + ErrClientStartFailed = errors.New("client start failed") + // ErrTooManyInflight is returned when the per-backend in-flight limit is reached. + ErrTooManyInflight = errors.New("too many in-flight requests") +) + +// serviceInfo holds metadata about a registered service. +type serviceInfo struct { + serviceID types.ServiceID +} + +type serviceNotification struct { + key ServiceKey + serviceID types.ServiceID +} + +// clientEntry holds an embedded NetBird client and tracks which services use it. +type clientEntry struct { + client *embed.Client + transport *http.Transport + // insecureTransport is a clone of transport with TLS verification disabled, + // used when per-target skip_tls_verify is set. + insecureTransport *http.Transport + services map[ServiceKey]serviceInfo + createdAt time.Time + started bool + // Per-backend in-flight limiting keyed by target host:port. + // TODO: clean up stale entries when backend targets change. + inflightMu sync.Mutex + inflightMap map[backendKey]chan struct{} + maxInflight int +} + +// acquireInflight attempts to acquire an in-flight slot for the given backend. +// It returns a release function that must always be called, and true on success. +func (e *clientEntry) acquireInflight(backend backendKey) (release func(), ok bool) { + noop := func() {} + if e.maxInflight <= 0 { + return noop, true + } + + e.inflightMu.Lock() + sem, exists := e.inflightMap[backend] + if !exists { + sem = make(chan struct{}, e.maxInflight) + e.inflightMap[backend] = sem + } + e.inflightMu.Unlock() + + select { + case sem <- struct{}{}: + return func() { <-sem }, true + default: + return noop, false + } +} + +// ClientConfig holds configuration for the embedded NetBird client. +type ClientConfig struct { + MgmtAddr string + WGPort uint16 + PreSharedKey string +} + +type statusNotifier interface { + NotifyStatus(ctx context.Context, accountID types.AccountID, serviceID types.ServiceID, connected bool) error +} + +type managementClient interface { + CreateProxyPeer(ctx context.Context, req *proto.CreateProxyPeerRequest, opts ...grpc.CallOption) (*proto.CreateProxyPeerResponse, error) +} + +// NetBird provides an http.RoundTripper implementation +// backed by underlying NetBird connections. +// Clients are keyed by AccountID, allowing multiple services to share the same connection. +type NetBird struct { + proxyID string + proxyAddr string + clientCfg ClientConfig + logger *log.Logger + mgmtClient managementClient + transportCfg transportConfig + + clientsMux sync.RWMutex + clients map[types.AccountID]*clientEntry + initLogOnce sync.Once + statusNotifier statusNotifier +} + +// ClientDebugInfo contains debug information about a client. +type ClientDebugInfo struct { + AccountID types.AccountID + ServiceCount int + ServiceKeys []string + HasClient bool + CreatedAt time.Time +} + +// accountIDContextKey is the context key for storing the account ID. +type accountIDContextKey struct{} + +// skipTLSVerifyContextKey is the context key for requesting insecure TLS. +type skipTLSVerifyContextKey struct{} + +// AddPeer registers a service for an account. If the account doesn't have a client yet, +// one is created by authenticating with the management server using the provided token. +// Multiple services can share the same client. +func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, key ServiceKey, authToken string, serviceID types.ServiceID) error { + si := serviceInfo{serviceID: serviceID} + + n.clientsMux.Lock() + + entry, exists := n.clients[accountID] + if exists { + entry.services[key] = si + started := entry.started + n.clientsMux.Unlock() + + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "service_key": key, + }).Debug("registered service with existing client") + + if started && n.statusNotifier != nil { + if err := n.statusNotifier.NotifyStatus(ctx, accountID, serviceID, true); err != nil { + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "service_key": key, + }).WithError(err).Warn("failed to notify status for existing client") + } + } + return nil + } + + entry, err := n.createClientEntry(ctx, accountID, key, authToken, si) + if err != nil { + n.clientsMux.Unlock() + return err + } + + n.clients[accountID] = entry + n.clientsMux.Unlock() + + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "service_key": key, + }).Info("created new client for account") + + // Attempt to start the client in the background; if this fails we will + // retry on the first request via RoundTrip. + go n.runClientStartup(ctx, accountID, entry.client) + + return nil +} + +// createClientEntry generates a WireGuard keypair, authenticates with management, +// and creates an embedded NetBird client. Must be called with clientsMux held. +func (n *NetBird) createClientEntry(ctx context.Context, accountID types.AccountID, key ServiceKey, authToken string, si serviceInfo) (*clientEntry, error) { + serviceID := si.serviceID + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "service_id": serviceID, + }).Debug("generating WireGuard keypair for new peer") + + privateKey, err := wgtypes.GeneratePrivateKey() + if err != nil { + return nil, fmt.Errorf("generate wireguard private key: %w", err) + } + publicKey := privateKey.PublicKey() + + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "service_id": serviceID, + "public_key": publicKey.String(), + }).Debug("authenticating new proxy peer with management") + + resp, err := n.mgmtClient.CreateProxyPeer(ctx, &proto.CreateProxyPeerRequest{ + ServiceId: string(serviceID), + AccountId: string(accountID), + Token: authToken, + WireguardPublicKey: publicKey.String(), + Cluster: n.proxyAddr, + }) + if err != nil { + return nil, fmt.Errorf("authenticate proxy peer with management: %w", err) + } + if resp != nil && !resp.GetSuccess() { + errMsg := "unknown error" + if resp.ErrorMessage != nil { + errMsg = *resp.ErrorMessage + } + return nil, fmt.Errorf("proxy peer authentication failed: %s", errMsg) + } + + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "service_id": serviceID, + "public_key": publicKey.String(), + }).Info("proxy peer authenticated successfully with management") + + n.initLogOnce.Do(func() { + if err := util.InitLog(log.WarnLevel.String(), util.LogConsole); err != nil { + n.logger.WithField("account_id", accountID).Warnf("failed to initialize embedded client logging: %v", err) + } + }) + + // Create embedded NetBird client with the generated private key. + // The peer has already been created via CreateProxyPeer RPC with the public key. + wgPort := int(n.clientCfg.WGPort) + client, err := embed.New(embed.Options{ + DeviceName: deviceNamePrefix + n.proxyID, + ManagementURL: n.clientCfg.MgmtAddr, + PrivateKey: privateKey.String(), + LogLevel: log.WarnLevel.String(), + BlockInbound: true, + WireguardPort: &wgPort, + PreSharedKey: n.clientCfg.PreSharedKey, + }) + if err != nil { + return nil, fmt.Errorf("create netbird client: %w", err) + } + + // Create a transport using the client dialer. We do this instead of using + // the client's HTTPClient to avoid issues with request validation that do + // not work with reverse proxied requests. + transport := &http.Transport{ + DialContext: dialWithTimeout(client.DialContext), + ForceAttemptHTTP2: true, + MaxIdleConns: n.transportCfg.maxIdleConns, + MaxIdleConnsPerHost: n.transportCfg.maxIdleConnsPerHost, + MaxConnsPerHost: n.transportCfg.maxConnsPerHost, + IdleConnTimeout: n.transportCfg.idleConnTimeout, + TLSHandshakeTimeout: n.transportCfg.tlsHandshakeTimeout, + ExpectContinueTimeout: n.transportCfg.expectContinueTimeout, + ResponseHeaderTimeout: n.transportCfg.responseHeaderTimeout, + WriteBufferSize: n.transportCfg.writeBufferSize, + ReadBufferSize: n.transportCfg.readBufferSize, + DisableCompression: n.transportCfg.disableCompression, + } + + insecureTransport := transport.Clone() + insecureTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec + + return &clientEntry{ + client: client, + services: map[ServiceKey]serviceInfo{key: si}, + transport: transport, + insecureTransport: insecureTransport, + createdAt: time.Now(), + started: false, + inflightMap: make(map[backendKey]chan struct{}), + maxInflight: n.transportCfg.maxInflight, + }, nil +} + +// runClientStartup starts the client and notifies registered services on success. +func (n *NetBird) runClientStartup(ctx context.Context, accountID types.AccountID, client *embed.Client) { + startCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := client.Start(startCtx); err != nil { + if errors.Is(err, context.DeadlineExceeded) { + n.logger.WithField("account_id", accountID).Warn("netbird client start timed out, will retry on first request") + } else { + n.logger.WithField("account_id", accountID).WithError(err).Error("failed to start netbird client") + } + return + } + + // Mark client as started and collect services to notify outside the lock. + n.clientsMux.Lock() + entry, exists := n.clients[accountID] + if exists { + entry.started = true + } + var toNotify []serviceNotification + if exists { + for key, info := range entry.services { + toNotify = append(toNotify, serviceNotification{key: key, serviceID: info.serviceID}) + } + } + n.clientsMux.Unlock() + + if n.statusNotifier == nil { + return + } + for _, sn := range toNotify { + if err := n.statusNotifier.NotifyStatus(ctx, accountID, sn.serviceID, true); err != nil { + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "service_key": sn.key, + }).WithError(err).Warn("failed to notify tunnel connection status") + } else { + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "service_key": sn.key, + }).Info("notified management about tunnel connection") + } + } +} + +// RemovePeer unregisters a service from an account. The client is only stopped +// when no services are using it anymore. +func (n *NetBird) RemovePeer(ctx context.Context, accountID types.AccountID, key ServiceKey) error { + n.clientsMux.Lock() + + entry, exists := n.clients[accountID] + if !exists { + n.clientsMux.Unlock() + n.logger.WithField("account_id", accountID).Debug("remove peer: account not found") + return nil + } + + si, svcExists := entry.services[key] + if !svcExists { + n.clientsMux.Unlock() + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "service_key": key, + }).Debug("remove peer: service not registered") + return nil + } + + delete(entry.services, key) + + stopClient := len(entry.services) == 0 + var client *embed.Client + var transport, insecureTransport *http.Transport + if stopClient { + n.logger.WithField("account_id", accountID).Info("stopping client, no more services") + client = entry.client + transport = entry.transport + insecureTransport = entry.insecureTransport + delete(n.clients, accountID) + } else { + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "service_key": key, + "remaining_services": len(entry.services), + }).Debug("unregistered service, client still in use") + } + n.clientsMux.Unlock() + + n.notifyDisconnect(ctx, accountID, key, si.serviceID) + + if stopClient { + transport.CloseIdleConnections() + insecureTransport.CloseIdleConnections() + if err := client.Stop(ctx); err != nil { + n.logger.WithField("account_id", accountID).WithError(err).Warn("failed to stop netbird client") + } + } + + return nil +} + +func (n *NetBird) notifyDisconnect(ctx context.Context, accountID types.AccountID, key ServiceKey, serviceID types.ServiceID) { + if n.statusNotifier == nil { + return + } + if err := n.statusNotifier.NotifyStatus(ctx, accountID, serviceID, false); err != nil { + if s, ok := grpcstatus.FromError(err); ok && s.Code() == codes.NotFound { + n.logger.WithField("service_key", key).Debug("service already removed, skipping disconnect notification") + } else { + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "service_key": key, + }).WithError(err).Warn("failed to notify tunnel disconnection status") + } + } +} + +// RoundTrip implements http.RoundTripper. It looks up the client for the account +// specified in the request context and uses it to dial the backend. +func (n *NetBird) RoundTrip(req *http.Request) (*http.Response, error) { + accountID := AccountIDFromContext(req.Context()) + if accountID == "" { + return nil, ErrNoAccountID + } + + // Copy references while holding lock, then unlock early to avoid blocking + // other requests during the potentially slow RoundTrip. + n.clientsMux.RLock() + entry, exists := n.clients[accountID] + if !exists { + n.clientsMux.RUnlock() + return nil, fmt.Errorf("%w for account: %s", ErrNoPeerConnection, accountID) + } + client := entry.client + transport := entry.transport + if skipTLSVerifyFromContext(req.Context()) { + transport = entry.insecureTransport + } + n.clientsMux.RUnlock() + + release, ok := entry.acquireInflight(backendKey(req.URL.Host)) + defer release() + if !ok { + return nil, ErrTooManyInflight + } + + // Attempt to start the client, if the client is already running then + // it will return an error that we ignore, if this hits a timeout then + // this request is unprocessable. + startCtx, cancel := context.WithTimeout(req.Context(), 30*time.Second) + defer cancel() + if err := client.Start(startCtx); err != nil { + if !errors.Is(err, embed.ErrClientAlreadyStarted) { + return nil, fmt.Errorf("%w: %w", ErrClientStartFailed, err) + } + } + + start := time.Now() + resp, err := transport.RoundTrip(req) + duration := time.Since(start) + + if err != nil { + n.logger.Debugf("roundtrip: method=%s host=%s url=%s account=%s duration=%s err=%v", + req.Method, req.Host, req.URL.String(), accountID, duration.Truncate(time.Millisecond), err) + return nil, err + } + + n.logger.Debugf("roundtrip: method=%s host=%s url=%s account=%s status=%d duration=%s", + req.Method, req.Host, req.URL.String(), accountID, resp.StatusCode, duration.Truncate(time.Millisecond)) + return resp, nil +} + +// StopAll stops all clients. +func (n *NetBird) StopAll(ctx context.Context) error { + n.clientsMux.Lock() + defer n.clientsMux.Unlock() + + var merr *multierror.Error + for accountID, entry := range n.clients { + entry.transport.CloseIdleConnections() + entry.insecureTransport.CloseIdleConnections() + if err := entry.client.Stop(ctx); err != nil { + n.logger.WithFields(log.Fields{ + "account_id": accountID, + }).WithError(err).Warn("failed to stop netbird client during shutdown") + merr = multierror.Append(merr, err) + } + } + maps.Clear(n.clients) + + return nberrors.FormatErrorOrNil(merr) +} + +// HasClient returns true if there is a client for the given account. +func (n *NetBird) HasClient(accountID types.AccountID) bool { + n.clientsMux.RLock() + defer n.clientsMux.RUnlock() + _, exists := n.clients[accountID] + return exists +} + +// ServiceCount returns the number of services registered for the given account. +// Returns 0 if the account has no client. +func (n *NetBird) ServiceCount(accountID types.AccountID) int { + n.clientsMux.RLock() + defer n.clientsMux.RUnlock() + entry, exists := n.clients[accountID] + if !exists { + return 0 + } + return len(entry.services) +} + +// ClientCount returns the total number of active clients. +func (n *NetBird) ClientCount() int { + n.clientsMux.RLock() + defer n.clientsMux.RUnlock() + return len(n.clients) +} + +// GetClient returns the embed.Client for the given account ID. +func (n *NetBird) GetClient(accountID types.AccountID) (*embed.Client, bool) { + n.clientsMux.RLock() + defer n.clientsMux.RUnlock() + entry, exists := n.clients[accountID] + if !exists { + return nil, false + } + return entry.client, true +} + +// ListClientsForDebug returns information about all clients for debug purposes. +func (n *NetBird) ListClientsForDebug() map[types.AccountID]ClientDebugInfo { + n.clientsMux.RLock() + defer n.clientsMux.RUnlock() + + result := make(map[types.AccountID]ClientDebugInfo) + for accountID, entry := range n.clients { + keys := make([]string, 0, len(entry.services)) + for k := range entry.services { + keys = append(keys, string(k)) + } + result[accountID] = ClientDebugInfo{ + AccountID: accountID, + ServiceCount: len(entry.services), + ServiceKeys: keys, + HasClient: entry.client != nil, + CreatedAt: entry.createdAt, + } + } + return result +} + +// ListClientsForStartup returns all embed.Client instances for health checks. +func (n *NetBird) ListClientsForStartup() map[types.AccountID]*embed.Client { + n.clientsMux.RLock() + defer n.clientsMux.RUnlock() + + result := make(map[types.AccountID]*embed.Client) + for accountID, entry := range n.clients { + if entry.client != nil { + result[accountID] = entry.client + } + } + return result +} + +// NewNetBird creates a new NetBird transport. Set clientCfg.WGPort to 0 for a random +// OS-assigned port. A fixed port only works with single-account deployments; +// multiple accounts will fail to bind the same port. +func NewNetBird(proxyID, proxyAddr string, clientCfg ClientConfig, logger *log.Logger, notifier statusNotifier, mgmtClient managementClient) *NetBird { + if logger == nil { + logger = log.StandardLogger() + } + return &NetBird{ + proxyID: proxyID, + proxyAddr: proxyAddr, + clientCfg: clientCfg, + logger: logger, + clients: make(map[types.AccountID]*clientEntry), + statusNotifier: notifier, + mgmtClient: mgmtClient, + transportCfg: loadTransportConfig(logger), + } +} + +// dialWithTimeout wraps a DialContext function so that any dial timeout +// stored in the context (via types.WithDialTimeout) is applied only to +// the connection establishment phase, not the full request lifetime. +func dialWithTimeout(dial func(ctx context.Context, network, addr string) (net.Conn, error)) func(ctx context.Context, network, addr string) (net.Conn, error) { + return func(ctx context.Context, network, addr string) (net.Conn, error) { + if d, ok := types.DialTimeoutFromContext(ctx); ok { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, d) + defer cancel() + } + return dial(ctx, network, addr) + } +} + +// WithAccountID adds the account ID to the context. +func WithAccountID(ctx context.Context, accountID types.AccountID) context.Context { + return context.WithValue(ctx, accountIDContextKey{}, accountID) +} + +// AccountIDFromContext retrieves the account ID from the context. +func AccountIDFromContext(ctx context.Context) types.AccountID { + v := ctx.Value(accountIDContextKey{}) + if v == nil { + return "" + } + accountID, ok := v.(types.AccountID) + if !ok { + return "" + } + return accountID +} + +// WithSkipTLSVerify marks the context to use an insecure transport that skips +// TLS certificate verification for the backend connection. +func WithSkipTLSVerify(ctx context.Context) context.Context { + return context.WithValue(ctx, skipTLSVerifyContextKey{}, true) +} + +func skipTLSVerifyFromContext(ctx context.Context) bool { + v, _ := ctx.Value(skipTLSVerifyContextKey{}).(bool) + return v +} diff --git a/proxy/internal/roundtrip/netbird_bench_test.go b/proxy/internal/roundtrip/netbird_bench_test.go new file mode 100644 index 000000000..330ea0332 --- /dev/null +++ b/proxy/internal/roundtrip/netbird_bench_test.go @@ -0,0 +1,112 @@ +package roundtrip + +import ( + "context" + "crypto/rand" + "math/big" + "sync" + "testing" + "time" + + "github.com/netbirdio/netbird/proxy/internal/types" +) + +// Simple benchmark for comparison with AddPeer contention. +func BenchmarkHasClient(b *testing.B) { + // Knobs for dialling in: + initialClientCount := 100 // Size of initial peer map to generate. + + nb := mockNetBird() + + var target types.AccountID + targetIndex, err := rand.Int(rand.Reader, big.NewInt(int64(initialClientCount))) + if err != nil { + b.Fatal(err) + } + for i := range initialClientCount { + id := types.AccountID(rand.Text()) + if int64(i) == targetIndex.Int64() { + target = id + } + nb.clients[id] = &clientEntry{ + services: map[ServiceKey]serviceInfo{ + ServiceKey(rand.Text()): { + serviceID: types.ServiceID(rand.Text()), + }, + }, + createdAt: time.Now(), + started: true, + } + } + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + nb.HasClient(target) + } + }) + b.StopTimer() +} + +func BenchmarkHasClientDuringAddPeer(b *testing.B) { + // Knobs for dialling in: + initialClientCount := 100 // Size of initial peer map to generate. + addPeerWorkers := 5 // Number of workers to concurrently call AddPeer. + + nb := mockNetBird() + + // Add random client entries to the netbird instance. + // We're trying to test map lock contention, so starting with + // a populated map should help with this. + // Pick a random one to target for retrieval later. + var target types.AccountID + targetIndex, err := rand.Int(rand.Reader, big.NewInt(int64(initialClientCount))) + if err != nil { + b.Fatal(err) + } + for i := range initialClientCount { + id := types.AccountID(rand.Text()) + if int64(i) == targetIndex.Int64() { + target = id + } + nb.clients[id] = &clientEntry{ + services: map[ServiceKey]serviceInfo{ + ServiceKey(rand.Text()): { + serviceID: types.ServiceID(rand.Text()), + }, + }, + createdAt: time.Now(), + started: true, + } + } + + // Launch workers that continuously call AddPeer with new random accountIDs. + ctx, cancel := context.WithCancel(b.Context()) + var wg sync.WaitGroup + for range addPeerWorkers { + wg.Add(1) + go func() { + defer wg.Done() + for ctx.Err() == nil { + if err := nb.AddPeer(ctx, + types.AccountID(rand.Text()), + ServiceKey(rand.Text()), + rand.Text(), + types.ServiceID(rand.Text())); err != nil { + return + } + } + }() + } + + // Benchmark calling HasClient during AddPeer contention. + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + nb.HasClient(target) + } + }) + b.StopTimer() + cancel() + wg.Wait() +} diff --git a/proxy/internal/roundtrip/netbird_test.go b/proxy/internal/roundtrip/netbird_test.go new file mode 100644 index 000000000..5444f6c11 --- /dev/null +++ b/proxy/internal/roundtrip/netbird_test.go @@ -0,0 +1,331 @@ +package roundtrip + +import ( + "context" + "net/http" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/shared/management/proto" +) + +type mockMgmtClient struct{} + +func (m *mockMgmtClient) CreateProxyPeer(_ context.Context, _ *proto.CreateProxyPeerRequest, _ ...grpc.CallOption) (*proto.CreateProxyPeerResponse, error) { + return &proto.CreateProxyPeerResponse{Success: true}, nil +} + +type mockStatusNotifier struct { + mu sync.Mutex + statuses []statusCall +} + +type statusCall struct { + accountID types.AccountID + serviceID types.ServiceID + connected bool +} + +func (m *mockStatusNotifier) NotifyStatus(_ context.Context, accountID types.AccountID, serviceID types.ServiceID, connected bool) error { + m.mu.Lock() + defer m.mu.Unlock() + m.statuses = append(m.statuses, statusCall{accountID, serviceID, connected}) + return nil +} + +func (m *mockStatusNotifier) calls() []statusCall { + m.mu.Lock() + defer m.mu.Unlock() + return append([]statusCall{}, m.statuses...) +} + +// mockNetBird creates a NetBird instance for testing without actually connecting. +// It uses an invalid management URL to prevent real connections. +func mockNetBird() *NetBird { + return NewNetBird("test-proxy", "invalid.test", ClientConfig{ + MgmtAddr: "http://invalid.test:9999", + WGPort: 0, + PreSharedKey: "", + }, nil, nil, &mockMgmtClient{}) +} + +func TestNetBird_AddPeer_CreatesClientForNewAccount(t *testing.T) { + nb := mockNetBird() + accountID := types.AccountID("account-1") + + // Initially no client exists. + assert.False(t, nb.HasClient(accountID), "should not have client before AddPeer") + assert.Equal(t, 0, nb.ServiceCount(accountID), "service count should be 0") + + // Add first service - this should create a new client. + err := nb.AddPeer(context.Background(), accountID, "domain1.test", "setup-key-1", types.ServiceID("proxy-1")) + require.NoError(t, err) + + assert.True(t, nb.HasClient(accountID), "should have client after AddPeer") + assert.Equal(t, 1, nb.ServiceCount(accountID), "service count should be 1") +} + +func TestNetBird_AddPeer_ReuseClientForSameAccount(t *testing.T) { + nb := mockNetBird() + accountID := types.AccountID("account-1") + + // Add first service. + err := nb.AddPeer(context.Background(), accountID, "domain1.test", "setup-key-1", types.ServiceID("proxy-1")) + require.NoError(t, err) + assert.Equal(t, 1, nb.ServiceCount(accountID)) + + // Add second service for the same account - should reuse existing client. + err = nb.AddPeer(context.Background(), accountID, "domain2.test", "setup-key-1", types.ServiceID("proxy-2")) + require.NoError(t, err) + assert.Equal(t, 2, nb.ServiceCount(accountID), "service count should be 2 after adding second service") + + // Add third service. + err = nb.AddPeer(context.Background(), accountID, "domain3.test", "setup-key-1", types.ServiceID("proxy-3")) + require.NoError(t, err) + assert.Equal(t, 3, nb.ServiceCount(accountID), "service count should be 3 after adding third service") + + // Still only one client. + assert.True(t, nb.HasClient(accountID)) +} + +func TestNetBird_AddPeer_SeparateClientsForDifferentAccounts(t *testing.T) { + nb := mockNetBird() + account1 := types.AccountID("account-1") + account2 := types.AccountID("account-2") + + // Add service for account 1. + err := nb.AddPeer(context.Background(), account1, "domain1.test", "setup-key-1", types.ServiceID("proxy-1")) + require.NoError(t, err) + + // Add service for account 2. + err = nb.AddPeer(context.Background(), account2, "domain2.test", "setup-key-2", types.ServiceID("proxy-2")) + require.NoError(t, err) + + // Both accounts should have their own clients. + assert.True(t, nb.HasClient(account1), "account1 should have client") + assert.True(t, nb.HasClient(account2), "account2 should have client") + assert.Equal(t, 1, nb.ServiceCount(account1), "account1 service count should be 1") + assert.Equal(t, 1, nb.ServiceCount(account2), "account2 service count should be 1") +} + +func TestNetBird_RemovePeer_KeepsClientWhenServicesRemain(t *testing.T) { + nb := mockNetBird() + accountID := types.AccountID("account-1") + + // Add multiple services. + err := nb.AddPeer(context.Background(), accountID, "domain1.test", "setup-key-1", types.ServiceID("proxy-1")) + require.NoError(t, err) + err = nb.AddPeer(context.Background(), accountID, "domain2.test", "setup-key-1", types.ServiceID("proxy-2")) + require.NoError(t, err) + err = nb.AddPeer(context.Background(), accountID, "domain3.test", "setup-key-1", types.ServiceID("proxy-3")) + require.NoError(t, err) + assert.Equal(t, 3, nb.ServiceCount(accountID)) + + // Remove one service - client should remain. + err = nb.RemovePeer(context.Background(), accountID, "domain1.test") + require.NoError(t, err) + assert.True(t, nb.HasClient(accountID), "client should remain after removing one service") + assert.Equal(t, 2, nb.ServiceCount(accountID), "service count should be 2") + + // Remove another service - client should still remain. + err = nb.RemovePeer(context.Background(), accountID, "domain2.test") + require.NoError(t, err) + assert.True(t, nb.HasClient(accountID), "client should remain after removing second service") + assert.Equal(t, 1, nb.ServiceCount(accountID), "service count should be 1") +} + +func TestNetBird_RemovePeer_RemovesClientWhenLastServiceRemoved(t *testing.T) { + nb := mockNetBird() + accountID := types.AccountID("account-1") + + // Add single service. + err := nb.AddPeer(context.Background(), accountID, "domain1.test", "setup-key-1", types.ServiceID("proxy-1")) + require.NoError(t, err) + assert.True(t, nb.HasClient(accountID)) + + // Remove the only service - client should be removed. + _ = nb.RemovePeer(context.Background(), accountID, "domain1.test") + + // After removing all services, client should be gone. + assert.False(t, nb.HasClient(accountID), "client should be removed after removing last service") + assert.Equal(t, 0, nb.ServiceCount(accountID), "service count should be 0") +} + +func TestNetBird_RemovePeer_NonExistentAccountIsNoop(t *testing.T) { + nb := mockNetBird() + accountID := types.AccountID("nonexistent-account") + + // Removing from non-existent account should not error. + err := nb.RemovePeer(context.Background(), accountID, "domain1.test") + assert.NoError(t, err, "removing from non-existent account should not error") +} + +func TestNetBird_RemovePeer_NonExistentServiceIsNoop(t *testing.T) { + nb := mockNetBird() + accountID := types.AccountID("account-1") + + // Add one service. + err := nb.AddPeer(context.Background(), accountID, "domain1.test", "setup-key-1", types.ServiceID("proxy-1")) + require.NoError(t, err) + + // Remove non-existent service - should not affect existing service. + err = nb.RemovePeer(context.Background(), accountID, "nonexistent.test") + require.NoError(t, err) + + // Original service should still be registered. + assert.True(t, nb.HasClient(accountID)) + assert.Equal(t, 1, nb.ServiceCount(accountID), "original service should remain") +} + +func TestWithAccountID_AndAccountIDFromContext(t *testing.T) { + ctx := context.Background() + accountID := types.AccountID("test-account") + + // Initially no account ID in context. + retrieved := AccountIDFromContext(ctx) + assert.True(t, retrieved == "", "should be empty when not set") + + // Add account ID to context. + ctx = WithAccountID(ctx, accountID) + retrieved = AccountIDFromContext(ctx) + assert.Equal(t, accountID, retrieved, "should retrieve the same account ID") +} + +func TestAccountIDFromContext_ReturnsEmptyForWrongType(t *testing.T) { + // Create context with wrong type for account ID key. + ctx := context.WithValue(context.Background(), accountIDContextKey{}, "wrong-type-string") + + retrieved := AccountIDFromContext(ctx) + assert.True(t, retrieved == "", "should return empty for wrong type") +} + +func TestNetBird_StopAll_StopsAllClients(t *testing.T) { + nb := mockNetBird() + account1 := types.AccountID("account-1") + account2 := types.AccountID("account-2") + account3 := types.AccountID("account-3") + + // Add services for multiple accounts. + err := nb.AddPeer(context.Background(), account1, "domain1.test", "key-1", types.ServiceID("proxy-1")) + require.NoError(t, err) + err = nb.AddPeer(context.Background(), account2, "domain2.test", "key-2", types.ServiceID("proxy-2")) + require.NoError(t, err) + err = nb.AddPeer(context.Background(), account3, "domain3.test", "key-3", types.ServiceID("proxy-3")) + require.NoError(t, err) + + assert.Equal(t, 3, nb.ClientCount(), "should have 3 clients") + + // Stop all clients. + _ = nb.StopAll(context.Background()) + + assert.Equal(t, 0, nb.ClientCount(), "should have 0 clients after StopAll") + assert.False(t, nb.HasClient(account1), "account1 should not have client") + assert.False(t, nb.HasClient(account2), "account2 should not have client") + assert.False(t, nb.HasClient(account3), "account3 should not have client") +} + +func TestNetBird_ClientCount(t *testing.T) { + nb := mockNetBird() + + assert.Equal(t, 0, nb.ClientCount(), "should start with 0 clients") + + // Add clients for different accounts. + err := nb.AddPeer(context.Background(), types.AccountID("account-1"), "domain1.test", "key-1", types.ServiceID("proxy-1")) + require.NoError(t, err) + assert.Equal(t, 1, nb.ClientCount()) + + err = nb.AddPeer(context.Background(), types.AccountID("account-2"), "domain2.test", "key-2", types.ServiceID("proxy-2")) + require.NoError(t, err) + assert.Equal(t, 2, nb.ClientCount()) + + // Adding service to existing account should not increase count. + err = nb.AddPeer(context.Background(), types.AccountID("account-1"), "domain1b.test", "key-1", types.ServiceID("proxy-1b")) + require.NoError(t, err) + assert.Equal(t, 2, nb.ClientCount(), "adding service to existing account should not increase client count") +} + +func TestNetBird_RoundTrip_RequiresAccountIDInContext(t *testing.T) { + nb := mockNetBird() + + // Create a request without account ID in context. + req, err := http.NewRequest("GET", "http://example.com/", nil) + require.NoError(t, err) + + // RoundTrip should fail because no account ID in context. + _, err = nb.RoundTrip(req) //nolint:bodyclose + require.ErrorIs(t, err, ErrNoAccountID) +} + +func TestNetBird_RoundTrip_RequiresExistingClient(t *testing.T) { + nb := mockNetBird() + accountID := types.AccountID("nonexistent-account") + + // Create a request with account ID but no client exists. + req, err := http.NewRequest("GET", "http://example.com/", nil) + require.NoError(t, err) + req = req.WithContext(WithAccountID(req.Context(), accountID)) + + // RoundTrip should fail because no client for this account. + _, err = nb.RoundTrip(req) //nolint:bodyclose // Error case, no response body + assert.Error(t, err) + assert.Contains(t, err.Error(), "no peer connection found for account") +} + +func TestNetBird_AddPeer_ExistingStartedClient_NotifiesStatus(t *testing.T) { + notifier := &mockStatusNotifier{} + nb := NewNetBird("test-proxy", "invalid.test", ClientConfig{ + MgmtAddr: "http://invalid.test:9999", + WGPort: 0, + PreSharedKey: "", + }, nil, notifier, &mockMgmtClient{}) + accountID := types.AccountID("account-1") + + // Add first service — creates a new client entry. + err := nb.AddPeer(context.Background(), accountID, "domain1.test", "key-1", types.ServiceID("svc-1")) + require.NoError(t, err) + + // Manually mark client as started to simulate background startup completing. + nb.clientsMux.Lock() + nb.clients[accountID].started = true + nb.clientsMux.Unlock() + + // Add second service — should notify immediately since client is already started. + err = nb.AddPeer(context.Background(), accountID, "domain2.test", "key-1", types.ServiceID("svc-2")) + require.NoError(t, err) + + calls := notifier.calls() + require.Len(t, calls, 1) + assert.Equal(t, accountID, calls[0].accountID) + assert.Equal(t, types.ServiceID("svc-2"), calls[0].serviceID) + assert.True(t, calls[0].connected) +} + +func TestNetBird_RemovePeer_NotifiesDisconnection(t *testing.T) { + notifier := &mockStatusNotifier{} + nb := NewNetBird("test-proxy", "invalid.test", ClientConfig{ + MgmtAddr: "http://invalid.test:9999", + WGPort: 0, + PreSharedKey: "", + }, nil, notifier, &mockMgmtClient{}) + accountID := types.AccountID("account-1") + + err := nb.AddPeer(context.Background(), accountID, "domain1.test", "key-1", types.ServiceID("svc-1")) + require.NoError(t, err) + err = nb.AddPeer(context.Background(), accountID, "domain2.test", "key-1", types.ServiceID("svc-2")) + require.NoError(t, err) + + // Remove one service — client stays, but disconnection notification fires. + err = nb.RemovePeer(context.Background(), accountID, "domain1.test") + require.NoError(t, err) + assert.True(t, nb.HasClient(accountID)) + + calls := notifier.calls() + require.Len(t, calls, 1) + assert.Equal(t, types.ServiceID("svc-1"), calls[0].serviceID) + assert.False(t, calls[0].connected) +} diff --git a/proxy/internal/roundtrip/transport.go b/proxy/internal/roundtrip/transport.go new file mode 100644 index 000000000..7c450bbb7 --- /dev/null +++ b/proxy/internal/roundtrip/transport.go @@ -0,0 +1,152 @@ +package roundtrip + +import ( + "os" + "strconv" + "time" + + log "github.com/sirupsen/logrus" +) + +// Environment variable names for tuning the backend HTTP transport. +const ( + EnvMaxIdleConns = "NB_PROXY_MAX_IDLE_CONNS" + EnvMaxIdleConnsPerHost = "NB_PROXY_MAX_IDLE_CONNS_PER_HOST" + EnvMaxConnsPerHost = "NB_PROXY_MAX_CONNS_PER_HOST" + EnvIdleConnTimeout = "NB_PROXY_IDLE_CONN_TIMEOUT" + EnvTLSHandshakeTimeout = "NB_PROXY_TLS_HANDSHAKE_TIMEOUT" + EnvExpectContinueTimeout = "NB_PROXY_EXPECT_CONTINUE_TIMEOUT" + EnvResponseHeaderTimeout = "NB_PROXY_RESPONSE_HEADER_TIMEOUT" + EnvWriteBufferSize = "NB_PROXY_WRITE_BUFFER_SIZE" + EnvReadBufferSize = "NB_PROXY_READ_BUFFER_SIZE" + EnvDisableCompression = "NB_PROXY_DISABLE_COMPRESSION" + EnvMaxInflight = "NB_PROXY_MAX_INFLIGHT" +) + +// transportConfig holds tunable parameters for the per-account HTTP transport. +type transportConfig struct { + maxIdleConns int + maxIdleConnsPerHost int + maxConnsPerHost int + idleConnTimeout time.Duration + tlsHandshakeTimeout time.Duration + expectContinueTimeout time.Duration + responseHeaderTimeout time.Duration + writeBufferSize int + readBufferSize int + disableCompression bool + // maxInflight limits per-backend concurrent requests. 0 means unlimited. + maxInflight int +} + +func defaultTransportConfig() transportConfig { + return transportConfig{ + maxIdleConns: 100, + maxIdleConnsPerHost: 100, + maxConnsPerHost: 0, // unlimited + idleConnTimeout: 90 * time.Second, + tlsHandshakeTimeout: 10 * time.Second, + expectContinueTimeout: 1 * time.Second, + } +} + +func loadTransportConfig(logger *log.Logger) transportConfig { + cfg := defaultTransportConfig() + + if v, ok := envInt(EnvMaxIdleConns, logger); ok { + cfg.maxIdleConns = v + } + if v, ok := envInt(EnvMaxIdleConnsPerHost, logger); ok { + cfg.maxIdleConnsPerHost = v + } + if v, ok := envInt(EnvMaxConnsPerHost, logger); ok { + cfg.maxConnsPerHost = v + } + if v, ok := envDuration(EnvIdleConnTimeout, logger); ok { + cfg.idleConnTimeout = v + } + if v, ok := envDuration(EnvTLSHandshakeTimeout, logger); ok { + cfg.tlsHandshakeTimeout = v + } + if v, ok := envDuration(EnvExpectContinueTimeout, logger); ok { + cfg.expectContinueTimeout = v + } + if v, ok := envDuration(EnvResponseHeaderTimeout, logger); ok { + cfg.responseHeaderTimeout = v + } + if v, ok := envInt(EnvWriteBufferSize, logger); ok { + cfg.writeBufferSize = v + } + if v, ok := envInt(EnvReadBufferSize, logger); ok { + cfg.readBufferSize = v + } + if v, ok := envBool(EnvDisableCompression, logger); ok { + cfg.disableCompression = v + } + if v, ok := envInt(EnvMaxInflight, logger); ok { + cfg.maxInflight = v + } + + logger.WithFields(log.Fields{ + "max_idle_conns": cfg.maxIdleConns, + "max_idle_conns_per_host": cfg.maxIdleConnsPerHost, + "max_conns_per_host": cfg.maxConnsPerHost, + "idle_conn_timeout": cfg.idleConnTimeout, + "tls_handshake_timeout": cfg.tlsHandshakeTimeout, + "expect_continue_timeout": cfg.expectContinueTimeout, + "response_header_timeout": cfg.responseHeaderTimeout, + "write_buffer_size": cfg.writeBufferSize, + "read_buffer_size": cfg.readBufferSize, + "disable_compression": cfg.disableCompression, + "max_inflight": cfg.maxInflight, + }).Debug("backend transport configuration") + + return cfg +} + +func envInt(key string, logger *log.Logger) (int, bool) { + s := os.Getenv(key) + if s == "" { + return 0, false + } + v, err := strconv.Atoi(s) + if err != nil { + logger.Warnf("failed to parse %s=%q as int: %v", key, s, err) + return 0, false + } + if v < 0 { + logger.Warnf("ignoring negative value for %s=%d", key, v) + return 0, false + } + return v, true +} + +func envDuration(key string, logger *log.Logger) (time.Duration, bool) { + s := os.Getenv(key) + if s == "" { + return 0, false + } + v, err := time.ParseDuration(s) + if err != nil { + logger.Warnf("failed to parse %s=%q as duration: %v", key, s, err) + return 0, false + } + if v < 0 { + logger.Warnf("ignoring negative value for %s=%s", key, v) + return 0, false + } + return v, true +} + +func envBool(key string, logger *log.Logger) (bool, bool) { + s := os.Getenv(key) + if s == "" { + return false, false + } + v, err := strconv.ParseBool(s) + if err != nil { + logger.Warnf("failed to parse %s=%q as bool: %v", key, s, err) + return false, false + } + return v, true +} diff --git a/proxy/internal/tcp/bench_test.go b/proxy/internal/tcp/bench_test.go new file mode 100644 index 000000000..049f8395d --- /dev/null +++ b/proxy/internal/tcp/bench_test.go @@ -0,0 +1,133 @@ +package tcp + +import ( + "bytes" + "crypto/tls" + "io" + "net" + "testing" +) + +// BenchmarkPeekClientHello_TLS measures the overhead of peeking at a real +// TLS ClientHello and extracting the SNI. This is the per-connection cost +// added to every TLS connection on the main listener. +func BenchmarkPeekClientHello_TLS(b *testing.B) { + // Pre-generate a ClientHello by capturing what crypto/tls sends. + clientConn, serverConn := net.Pipe() + go func() { + tlsConn := tls.Client(clientConn, &tls.Config{ + ServerName: "app.example.com", + InsecureSkipVerify: true, //nolint:gosec + }) + _ = tlsConn.Handshake() + }() + + var hello []byte + buf := make([]byte, 16384) + n, _ := serverConn.Read(buf) + hello = make([]byte, n) + copy(hello, buf[:n]) + clientConn.Close() + serverConn.Close() + + b.ResetTimer() + b.ReportAllocs() + + for b.Loop() { + r := bytes.NewReader(hello) + conn := &readerConn{Reader: r} + sni, wrapped, err := PeekClientHello(conn) + if err != nil { + b.Fatal(err) + } + if sni != "app.example.com" { + b.Fatalf("unexpected SNI: %q", sni) + } + // Simulate draining the peeked bytes (what the HTTP server would do). + _, _ = io.Copy(io.Discard, wrapped) + } +} + +// BenchmarkPeekClientHello_NonTLS measures peek overhead for non-TLS +// connections that hit the fast non-handshake exit path. +func BenchmarkPeekClientHello_NonTLS(b *testing.B) { + httpReq := []byte("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n") + + b.ResetTimer() + b.ReportAllocs() + + for b.Loop() { + r := bytes.NewReader(httpReq) + conn := &readerConn{Reader: r} + _, wrapped, err := PeekClientHello(conn) + if err != nil { + b.Fatal(err) + } + _, _ = io.Copy(io.Discard, wrapped) + } +} + +// BenchmarkPeekedConn_Read measures the read overhead of the peekedConn +// wrapper compared to a plain connection read. The peeked bytes use +// io.MultiReader which adds one indirection per Read call. +func BenchmarkPeekedConn_Read(b *testing.B) { + data := make([]byte, 4096) + peeked := make([]byte, 512) + buf := make([]byte, 1024) + + b.ResetTimer() + b.ReportAllocs() + + for b.Loop() { + r := bytes.NewReader(data) + conn := &readerConn{Reader: r} + pc := newPeekedConn(conn, peeked) + for { + _, err := pc.Read(buf) + if err != nil { + break + } + } + } +} + +// BenchmarkExtractSNI measures just the in-memory SNI parsing cost, +// excluding I/O. +func BenchmarkExtractSNI(b *testing.B) { + clientConn, serverConn := net.Pipe() + go func() { + tlsConn := tls.Client(clientConn, &tls.Config{ + ServerName: "app.example.com", + InsecureSkipVerify: true, //nolint:gosec + }) + _ = tlsConn.Handshake() + }() + + buf := make([]byte, 16384) + n, _ := serverConn.Read(buf) + payload := make([]byte, n-tlsRecordHeaderLen) + copy(payload, buf[tlsRecordHeaderLen:n]) + clientConn.Close() + serverConn.Close() + + b.ResetTimer() + b.ReportAllocs() + + for b.Loop() { + sni := extractSNI(payload) + if sni != "app.example.com" { + b.Fatalf("unexpected SNI: %q", sni) + } + } +} + +// readerConn wraps an io.Reader as a net.Conn for benchmarking. +// Only Read is functional; all other methods are no-ops. +type readerConn struct { + io.Reader + net.Conn +} + +func (c *readerConn) Read(b []byte) (int, error) { + return c.Reader.Read(b) +} diff --git a/proxy/internal/tcp/chanlistener.go b/proxy/internal/tcp/chanlistener.go new file mode 100644 index 000000000..ee64bc0a2 --- /dev/null +++ b/proxy/internal/tcp/chanlistener.go @@ -0,0 +1,76 @@ +package tcp + +import ( + "net" + "sync" +) + +// chanListener implements net.Listener by reading connections from a channel. +// It allows the SNI router to feed HTTP connections to http.Server.ServeTLS. +type chanListener struct { + ch chan net.Conn + addr net.Addr + once sync.Once + closed chan struct{} +} + +func newChanListener(ch chan net.Conn, addr net.Addr) *chanListener { + return &chanListener{ + ch: ch, + addr: addr, + closed: make(chan struct{}), + } +} + +// Accept waits for and returns the next connection from the channel. +func (l *chanListener) Accept() (net.Conn, error) { + for { + select { + case conn, ok := <-l.ch: + if !ok { + return nil, net.ErrClosed + } + return conn, nil + case <-l.closed: + // Drain buffered connections before returning. + for { + select { + case conn, ok := <-l.ch: + if !ok { + return nil, net.ErrClosed + } + _ = conn.Close() + default: + return nil, net.ErrClosed + } + } + } + } +} + +// Close signals the listener to stop accepting connections and drains +// any buffered connections that have not yet been accepted. +func (l *chanListener) Close() error { + l.once.Do(func() { + close(l.closed) + for { + select { + case conn, ok := <-l.ch: + if !ok { + return + } + _ = conn.Close() + default: + return + } + } + }) + return nil +} + +// Addr returns the listener's network address. +func (l *chanListener) Addr() net.Addr { + return l.addr +} + +var _ net.Listener = (*chanListener)(nil) diff --git a/proxy/internal/tcp/peekedconn.go b/proxy/internal/tcp/peekedconn.go new file mode 100644 index 000000000..26f3e5c7c --- /dev/null +++ b/proxy/internal/tcp/peekedconn.go @@ -0,0 +1,39 @@ +package tcp + +import ( + "bytes" + "io" + "net" +) + +// peekedConn wraps a net.Conn and prepends previously peeked bytes +// so that readers see the full original stream transparently. +type peekedConn struct { + net.Conn + reader io.Reader +} + +func newPeekedConn(conn net.Conn, peeked []byte) *peekedConn { + return &peekedConn{ + Conn: conn, + reader: io.MultiReader(bytes.NewReader(peeked), conn), + } +} + +// Read replays the peeked bytes first, then reads from the underlying conn. +func (c *peekedConn) Read(b []byte) (int, error) { + return c.reader.Read(b) +} + +// CloseWrite delegates to the underlying connection if it supports +// half-close (e.g. *net.TCPConn). Without this, embedding net.Conn +// as an interface hides the concrete type's CloseWrite method, making +// half-close a silent no-op for all SNI-routed connections. +func (c *peekedConn) CloseWrite() error { + if hc, ok := c.Conn.(halfCloser); ok { + return hc.CloseWrite() + } + return nil +} + +var _ halfCloser = (*peekedConn)(nil) diff --git a/proxy/internal/tcp/proxyprotocol.go b/proxy/internal/tcp/proxyprotocol.go new file mode 100644 index 000000000..699b75a5d --- /dev/null +++ b/proxy/internal/tcp/proxyprotocol.go @@ -0,0 +1,29 @@ +package tcp + +import ( + "fmt" + "net" + + "github.com/pires/go-proxyproto" +) + +// writeProxyProtoV2 sends a PROXY protocol v2 header to the backend connection, +// conveying the real client address. +func writeProxyProtoV2(client, backend net.Conn) error { + tp := proxyproto.TCPv4 + if addr, ok := client.RemoteAddr().(*net.TCPAddr); ok && addr.IP.To4() == nil { + tp = proxyproto.TCPv6 + } + + header := &proxyproto.Header{ + Version: 2, + Command: proxyproto.PROXY, + TransportProtocol: tp, + SourceAddr: client.RemoteAddr(), + DestinationAddr: client.LocalAddr(), + } + if _, err := header.WriteTo(backend); err != nil { + return fmt.Errorf("write PROXY protocol v2 header: %w", err) + } + return nil +} diff --git a/proxy/internal/tcp/proxyprotocol_test.go b/proxy/internal/tcp/proxyprotocol_test.go new file mode 100644 index 000000000..f8c48b2ab --- /dev/null +++ b/proxy/internal/tcp/proxyprotocol_test.go @@ -0,0 +1,128 @@ +package tcp + +import ( + "bufio" + "net" + "testing" + + "github.com/pires/go-proxyproto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWriteProxyProtoV2_IPv4(t *testing.T) { + // Set up a real TCP listener and dial to get connections with real addresses. + ln, err := net.Listen("tcp4", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + var serverConn net.Conn + accepted := make(chan struct{}) + go func() { + var err error + serverConn, err = ln.Accept() + if err != nil { + t.Error("accept failed:", err) + } + close(accepted) + }() + + clientConn, err := net.Dial("tcp4", ln.Addr().String()) + require.NoError(t, err) + defer clientConn.Close() + + <-accepted + defer serverConn.Close() + + // Use a pipe as the backend: write the header to one end, read from the other. + backendRead, backendWrite := net.Pipe() + defer backendRead.Close() + defer backendWrite.Close() + + // serverConn is the "client" arg: RemoteAddr is the source, LocalAddr is the destination. + writeDone := make(chan error, 1) + go func() { + writeDone <- writeProxyProtoV2(serverConn, backendWrite) + }() + + // Read the PROXY protocol header from the backend read side. + header, err := proxyproto.Read(bufio.NewReader(backendRead)) + require.NoError(t, err) + require.NotNil(t, header, "should have received a proxy protocol header") + + writeErr := <-writeDone + require.NoError(t, writeErr) + + assert.Equal(t, byte(2), header.Version, "version should be 2") + assert.Equal(t, proxyproto.PROXY, header.Command, "command should be PROXY") + assert.Equal(t, proxyproto.TCPv4, header.TransportProtocol, "transport should be TCPv4") + + // serverConn.RemoteAddr() is the client's address (source in the header). + expectedSrc := serverConn.RemoteAddr().(*net.TCPAddr) + actualSrc := header.SourceAddr.(*net.TCPAddr) + assert.Equal(t, expectedSrc.IP.String(), actualSrc.IP.String(), "source IP should match client remote addr") + assert.Equal(t, expectedSrc.Port, actualSrc.Port, "source port should match client remote addr") + + // serverConn.LocalAddr() is the server's address (destination in the header). + expectedDst := serverConn.LocalAddr().(*net.TCPAddr) + actualDst := header.DestinationAddr.(*net.TCPAddr) + assert.Equal(t, expectedDst.IP.String(), actualDst.IP.String(), "destination IP should match server local addr") + assert.Equal(t, expectedDst.Port, actualDst.Port, "destination port should match server local addr") +} + +func TestWriteProxyProtoV2_IPv6(t *testing.T) { + // Set up a real TCP6 listener on loopback. + ln, err := net.Listen("tcp6", "[::1]:0") + if err != nil { + t.Skip("IPv6 not available:", err) + } + defer ln.Close() + + var serverConn net.Conn + accepted := make(chan struct{}) + go func() { + var err error + serverConn, err = ln.Accept() + if err != nil { + t.Error("accept failed:", err) + } + close(accepted) + }() + + clientConn, err := net.Dial("tcp6", ln.Addr().String()) + require.NoError(t, err) + defer clientConn.Close() + + <-accepted + defer serverConn.Close() + + backendRead, backendWrite := net.Pipe() + defer backendRead.Close() + defer backendWrite.Close() + + writeDone := make(chan error, 1) + go func() { + writeDone <- writeProxyProtoV2(serverConn, backendWrite) + }() + + header, err := proxyproto.Read(bufio.NewReader(backendRead)) + require.NoError(t, err) + require.NotNil(t, header, "should have received a proxy protocol header") + + writeErr := <-writeDone + require.NoError(t, writeErr) + + assert.Equal(t, byte(2), header.Version, "version should be 2") + assert.Equal(t, proxyproto.PROXY, header.Command, "command should be PROXY") + assert.Equal(t, proxyproto.TCPv6, header.TransportProtocol, "transport should be TCPv6") + + expectedSrc := serverConn.RemoteAddr().(*net.TCPAddr) + actualSrc := header.SourceAddr.(*net.TCPAddr) + assert.Equal(t, expectedSrc.IP.String(), actualSrc.IP.String(), "source IP should match client remote addr") + assert.Equal(t, expectedSrc.Port, actualSrc.Port, "source port should match client remote addr") + + expectedDst := serverConn.LocalAddr().(*net.TCPAddr) + actualDst := header.DestinationAddr.(*net.TCPAddr) + assert.Equal(t, expectedDst.IP.String(), actualDst.IP.String(), "destination IP should match server local addr") + assert.Equal(t, expectedDst.Port, actualDst.Port, "destination port should match server local addr") +} diff --git a/proxy/internal/tcp/relay.go b/proxy/internal/tcp/relay.go new file mode 100644 index 000000000..39949818d --- /dev/null +++ b/proxy/internal/tcp/relay.go @@ -0,0 +1,156 @@ +package tcp + +import ( + "context" + "errors" + "io" + "net" + "sync" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/proxy/internal/netutil" +) + +// errIdleTimeout is returned when a relay connection is closed due to inactivity. +var errIdleTimeout = errors.New("idle timeout") + +// DefaultIdleTimeout is the default idle timeout for TCP relay connections. +// A zero value disables idle timeout checking. +const DefaultIdleTimeout = 5 * time.Minute + +// halfCloser is implemented by connections that support half-close +// (e.g. *net.TCPConn). When one copy direction finishes, we signal +// EOF to the remote by closing the write side while keeping the read +// side open so the other direction can drain. +type halfCloser interface { + CloseWrite() error +} + +// copyBufPool avoids allocating a new 32KB buffer per io.Copy call. +var copyBufPool = sync.Pool{ + New: func() any { + buf := make([]byte, 32*1024) + return &buf + }, +} + +// Relay copies data bidirectionally between src and dst until both +// sides are done or the context is canceled. When idleTimeout is +// non-zero, each direction's read is deadline-guarded; if no data +// flows within the timeout the connection is torn down. When one +// direction finishes, it half-closes the write side of the +// destination (if supported) to signal EOF, allowing the other +// direction to drain gracefully before the full connection teardown. +func Relay(ctx context.Context, logger *log.Entry, src, dst net.Conn, idleTimeout time.Duration) (srcToDst, dstToSrc int64) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + go func() { + <-ctx.Done() + _ = src.Close() + _ = dst.Close() + }() + + var wg sync.WaitGroup + wg.Add(2) + + var errSrcToDst, errDstToSrc error + + go func() { + defer wg.Done() + srcToDst, errSrcToDst = copyWithIdleTimeout(dst, src, idleTimeout) + halfClose(dst) + cancel() + }() + + go func() { + defer wg.Done() + dstToSrc, errDstToSrc = copyWithIdleTimeout(src, dst, idleTimeout) + halfClose(src) + cancel() + }() + + wg.Wait() + + if errors.Is(errSrcToDst, errIdleTimeout) || errors.Is(errDstToSrc, errIdleTimeout) { + logger.Debug("relay closed due to idle timeout") + } + if errSrcToDst != nil && !isExpectedCopyError(errSrcToDst) { + logger.Debugf("relay copy error (src→dst): %v", errSrcToDst) + } + if errDstToSrc != nil && !isExpectedCopyError(errDstToSrc) { + logger.Debugf("relay copy error (dst→src): %v", errDstToSrc) + } + + return srcToDst, dstToSrc +} + +// copyWithIdleTimeout copies from src to dst using a pooled buffer. +// When idleTimeout > 0 it sets a read deadline on src before each +// read and treats a timeout as an idle-triggered close. +func copyWithIdleTimeout(dst io.Writer, src io.Reader, idleTimeout time.Duration) (int64, error) { + bufp := copyBufPool.Get().(*[]byte) + defer copyBufPool.Put(bufp) + + if idleTimeout <= 0 { + return io.CopyBuffer(dst, src, *bufp) + } + + conn, ok := src.(net.Conn) + if !ok { + return io.CopyBuffer(dst, src, *bufp) + } + + buf := *bufp + var total int64 + for { + if err := conn.SetReadDeadline(time.Now().Add(idleTimeout)); err != nil { + return total, err + } + nr, readErr := src.Read(buf) + if nr > 0 { + n, err := checkedWrite(dst, buf[:nr]) + total += n + if err != nil { + return total, err + } + } + if readErr != nil { + if netutil.IsTimeout(readErr) { + return total, errIdleTimeout + } + return total, readErr + } + } +} + +// checkedWrite writes buf to dst and returns the number of bytes written. +// It guards against short writes and negative counts per io.Copy convention. +func checkedWrite(dst io.Writer, buf []byte) (int64, error) { + nw, err := dst.Write(buf) + if nw < 0 || nw > len(buf) { + nw = 0 + } + if err != nil { + return int64(nw), err + } + if nw != len(buf) { + return int64(nw), io.ErrShortWrite + } + return int64(nw), nil +} + +func isExpectedCopyError(err error) bool { + return errors.Is(err, errIdleTimeout) || netutil.IsExpectedError(err) +} + +// halfClose attempts to half-close the write side of the connection. +// If the connection does not support half-close, this is a no-op. +func halfClose(conn net.Conn) { + if hc, ok := conn.(halfCloser); ok { + // Best-effort; the full close will follow shortly. + _ = hc.CloseWrite() + } +} diff --git a/proxy/internal/tcp/relay_test.go b/proxy/internal/tcp/relay_test.go new file mode 100644 index 000000000..e42d65b9d --- /dev/null +++ b/proxy/internal/tcp/relay_test.go @@ -0,0 +1,210 @@ +package tcp + +import ( + "context" + "fmt" + "io" + "net" + "testing" + "time" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/netutil" +) + +func TestRelay_BidirectionalCopy(t *testing.T) { + srcClient, srcServer := net.Pipe() + dstClient, dstServer := net.Pipe() + + logger := log.NewEntry(log.StandardLogger()) + ctx := context.Background() + + srcData := []byte("hello from src") + dstData := []byte("hello from dst") + + // dst side: write response first, then read + close. + go func() { + _, _ = dstClient.Write(dstData) + buf := make([]byte, 256) + _, _ = dstClient.Read(buf) + dstClient.Close() + }() + + // src side: read the response, then send data + close. + go func() { + buf := make([]byte, 256) + _, _ = srcClient.Read(buf) + _, _ = srcClient.Write(srcData) + srcClient.Close() + }() + + s2d, d2s := Relay(ctx, logger, srcServer, dstServer, 0) + + assert.Equal(t, int64(len(srcData)), s2d, "bytes src→dst") + assert.Equal(t, int64(len(dstData)), d2s, "bytes dst→src") +} + +func TestRelay_ContextCancellation(t *testing.T) { + srcClient, srcServer := net.Pipe() + dstClient, dstServer := net.Pipe() + defer srcClient.Close() + defer dstClient.Close() + + logger := log.NewEntry(log.StandardLogger()) + ctx, cancel := context.WithCancel(context.Background()) + + done := make(chan struct{}) + go func() { + Relay(ctx, logger, srcServer, dstServer, 0) + close(done) + }() + + // Cancel should cause Relay to return. + cancel() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("Relay did not return after context cancellation") + } +} + +func TestRelay_OneSideClosed(t *testing.T) { + srcClient, srcServer := net.Pipe() + dstClient, dstServer := net.Pipe() + defer dstClient.Close() + + logger := log.NewEntry(log.StandardLogger()) + ctx := context.Background() + + // Close src immediately. Relay should complete without hanging. + srcClient.Close() + + done := make(chan struct{}) + go func() { + Relay(ctx, logger, srcServer, dstServer, 0) + close(done) + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("Relay did not return after one side closed") + } +} + +func TestRelay_LargeTransfer(t *testing.T) { + srcClient, srcServer := net.Pipe() + dstClient, dstServer := net.Pipe() + + logger := log.NewEntry(log.StandardLogger()) + ctx := context.Background() + + // 1MB of data. + data := make([]byte, 1<<20) + for i := range data { + data[i] = byte(i % 256) + } + + go func() { + _, _ = srcClient.Write(data) + srcClient.Close() + }() + + errCh := make(chan error, 1) + go func() { + received, err := io.ReadAll(dstClient) + if err != nil { + errCh <- err + return + } + if len(received) != len(data) { + errCh <- fmt.Errorf("expected %d bytes, got %d", len(data), len(received)) + return + } + errCh <- nil + dstClient.Close() + }() + + s2d, _ := Relay(ctx, logger, srcServer, dstServer, 0) + assert.Equal(t, int64(len(data)), s2d, "should transfer all bytes") + require.NoError(t, <-errCh) +} + +func TestRelay_IdleTimeout(t *testing.T) { + // Use real TCP connections so SetReadDeadline works (net.Pipe + // does not support deadlines). + srcLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer srcLn.Close() + + dstLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer dstLn.Close() + + srcClient, err := net.Dial("tcp", srcLn.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer srcClient.Close() + + srcServer, err := srcLn.Accept() + if err != nil { + t.Fatal(err) + } + + dstClient, err := net.Dial("tcp", dstLn.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer dstClient.Close() + + dstServer, err := dstLn.Accept() + if err != nil { + t.Fatal(err) + } + + logger := log.NewEntry(log.StandardLogger()) + ctx := context.Background() + + // Send initial data to prove the relay works. + go func() { + _, _ = srcClient.Write([]byte("ping")) + }() + + done := make(chan struct{}) + var s2d, d2s int64 + go func() { + s2d, d2s = Relay(ctx, logger, srcServer, dstServer, 200*time.Millisecond) + close(done) + }() + + // Read the forwarded data on the dst side. + buf := make([]byte, 64) + n, err := dstClient.Read(buf) + assert.NoError(t, err) + assert.Equal(t, "ping", string(buf[:n])) + + // Now stop sending. The relay should close after the idle timeout. + select { + case <-done: + assert.Greater(t, s2d, int64(0), "should have transferred initial data") + _ = d2s + case <-time.After(5 * time.Second): + t.Fatal("Relay did not exit after idle timeout") + } +} + +func TestIsExpectedError(t *testing.T) { + assert.True(t, netutil.IsExpectedError(net.ErrClosed)) + assert.True(t, netutil.IsExpectedError(context.Canceled)) + assert.True(t, netutil.IsExpectedError(io.EOF)) + assert.False(t, netutil.IsExpectedError(io.ErrUnexpectedEOF)) +} diff --git a/proxy/internal/tcp/router.go b/proxy/internal/tcp/router.go new file mode 100644 index 000000000..8255c36d3 --- /dev/null +++ b/proxy/internal/tcp/router.go @@ -0,0 +1,658 @@ +package tcp + +import ( + "context" + "errors" + "fmt" + "net" + "net/netip" + "slices" + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/proxy/internal/accesslog" + "github.com/netbirdio/netbird/proxy/internal/restrict" + "github.com/netbirdio/netbird/proxy/internal/types" +) + +// defaultDialTimeout is the fallback dial timeout when no per-route +// timeout is configured. +const defaultDialTimeout = 30 * time.Second + +// errAccessRestricted is returned by relayTCP for access restriction +// denials so callers can skip warn-level logging (already logged at debug). +var errAccessRestricted = errors.New("rejected by access restrictions") + +// SNIHost is a typed key for SNI hostname lookups. +type SNIHost string + +// RouteType specifies how a connection should be handled. +type RouteType int + +const ( + // RouteHTTP routes the connection through the HTTP reverse proxy. + RouteHTTP RouteType = iota + // RouteTCP relays the connection directly to the backend (TLS passthrough). + RouteTCP +) + +const ( + // sniPeekTimeout is the deadline for reading the TLS ClientHello. + sniPeekTimeout = 5 * time.Second + // DefaultDrainTimeout is the default grace period for in-flight relay + // connections to finish during shutdown. + DefaultDrainTimeout = 30 * time.Second + // DefaultMaxRelayConns is the default cap on concurrent TCP relay connections per router. + DefaultMaxRelayConns = 4096 + // httpChannelBuffer is the capacity of the channel feeding HTTP connections. + httpChannelBuffer = 4096 +) + +// DialResolver returns a DialContextFunc for the given account. +type DialResolver func(accountID types.AccountID) (types.DialContextFunc, error) + +// Route describes where a connection for a given SNI should be sent. +type Route struct { + Type RouteType + AccountID types.AccountID + ServiceID types.ServiceID + // Domain is the service's configured domain, used for access log entries. + Domain string + // Protocol is the frontend protocol (tcp, tls), used for access log entries. + Protocol accesslog.Protocol + // Target is the backend address for TCP relay (e.g. "10.0.0.5:5432"). + Target string + // ProxyProtocol enables sending a PROXY protocol v2 header to the backend. + ProxyProtocol bool + // DialTimeout overrides the default dial timeout for this route. + // Zero uses defaultDialTimeout. + DialTimeout time.Duration + // SessionIdleTimeout overrides the default idle timeout for relay connections. + // Zero uses DefaultIdleTimeout. + SessionIdleTimeout time.Duration + // Filter holds connection-level IP/geo restrictions. Nil means no restrictions. + Filter *restrict.Filter +} + +// l4Logger sends layer-4 access log entries to the management server. +type l4Logger interface { + LogL4(entry accesslog.L4Entry) +} + +// RelayObserver receives callbacks for TCP relay lifecycle events. +// All methods must be safe for concurrent use. +type RelayObserver interface { + TCPRelayStarted(accountID types.AccountID) + TCPRelayEnded(accountID types.AccountID, duration time.Duration, srcToDst, dstToSrc int64) + TCPRelayDialError(accountID types.AccountID) + TCPRelayRejected(accountID types.AccountID) +} + +// Router accepts raw TCP connections on a shared listener, peeks at +// the TLS ClientHello to extract the SNI, and routes the connection +// to either the HTTP reverse proxy or a direct TCP relay. +type Router struct { + logger *log.Logger + // httpCh is immutable after construction: set only in NewRouter, nil in NewPortRouter. + httpCh chan net.Conn + httpListener *chanListener + mu sync.RWMutex + routes map[SNIHost][]Route + fallback *Route + draining bool + dialResolve DialResolver + activeConns sync.WaitGroup + activeRelays sync.WaitGroup + relaySem chan struct{} + drainDone chan struct{} + observer RelayObserver + accessLog l4Logger + geo restrict.GeoResolver + // svcCtxs tracks a context per service ID. All relay goroutines for a + // service derive from its context; canceling it kills them immediately. + svcCtxs map[types.ServiceID]context.Context + svcCancels map[types.ServiceID]context.CancelFunc +} + +// NewRouter creates a new SNI-based connection router. +func NewRouter(logger *log.Logger, dialResolve DialResolver, addr net.Addr) *Router { + httpCh := make(chan net.Conn, httpChannelBuffer) + return &Router{ + logger: logger, + httpCh: httpCh, + httpListener: newChanListener(httpCh, addr), + routes: make(map[SNIHost][]Route), + dialResolve: dialResolve, + relaySem: make(chan struct{}, DefaultMaxRelayConns), + svcCtxs: make(map[types.ServiceID]context.Context), + svcCancels: make(map[types.ServiceID]context.CancelFunc), + } +} + +// NewPortRouter creates a Router for a dedicated port without an HTTP +// channel. Connections that don't match any SNI route fall through to +// the fallback relay (if set) or are closed. +func NewPortRouter(logger *log.Logger, dialResolve DialResolver) *Router { + return &Router{ + logger: logger, + routes: make(map[SNIHost][]Route), + dialResolve: dialResolve, + relaySem: make(chan struct{}, DefaultMaxRelayConns), + svcCtxs: make(map[types.ServiceID]context.Context), + svcCancels: make(map[types.ServiceID]context.CancelFunc), + } +} + +// HTTPListener returns a net.Listener that yields connections routed +// to the HTTP handler. Use this with http.Server.ServeTLS. +func (r *Router) HTTPListener() net.Listener { + return r.httpListener +} + +// AddRoute registers an SNI route. Multiple routes for the same host are +// stored and resolved by priority at lookup time (HTTP > TCP). +// Empty host is ignored to prevent conflicts with ECH/ESNI fallback. +func (r *Router) AddRoute(host SNIHost, route Route) { + host = SNIHost(strings.ToLower(string(host))) + if host == "" { + return + } + + r.mu.Lock() + defer r.mu.Unlock() + + routes := r.routes[host] + for i, existing := range routes { + if existing.ServiceID == route.ServiceID { + r.cancelServiceLocked(route.ServiceID) + routes[i] = route + return + } + } + r.routes[host] = append(routes, route) +} + +// RemoveRoute removes the route for the given host and service ID. +// Active relay connections for the service are closed immediately. +// If other routes remain for the host, they are preserved. +func (r *Router) RemoveRoute(host SNIHost, svcID types.ServiceID) { + host = SNIHost(strings.ToLower(string(host))) + + r.mu.Lock() + defer r.mu.Unlock() + + r.routes[host] = slices.DeleteFunc(r.routes[host], func(route Route) bool { + return route.ServiceID == svcID + }) + if len(r.routes[host]) == 0 { + delete(r.routes, host) + } + r.cancelServiceLocked(svcID) +} + +// SetFallback registers a catch-all route for connections that don't +// match any SNI route. On a port router this handles plain TCP relay; +// on the main router it takes priority over the HTTP channel. +func (r *Router) SetFallback(route Route) { + r.mu.Lock() + defer r.mu.Unlock() + r.fallback = &route +} + +// RemoveFallback clears the catch-all fallback route and closes any +// active relay connections for the given service. +func (r *Router) RemoveFallback(svcID types.ServiceID) { + r.mu.Lock() + defer r.mu.Unlock() + r.fallback = nil + r.cancelServiceLocked(svcID) +} + +// SetObserver sets the relay lifecycle observer. Must be called before Serve. +func (r *Router) SetObserver(obs RelayObserver) { + r.mu.Lock() + defer r.mu.Unlock() + r.observer = obs +} + +// SetAccessLogger sets the L4 access logger. Must be called before Serve. +func (r *Router) SetAccessLogger(l l4Logger) { + r.mu.Lock() + defer r.mu.Unlock() + r.accessLog = l +} + +// getObserver returns the current relay observer under the read lock. +func (r *Router) getObserver() RelayObserver { + r.mu.RLock() + defer r.mu.RUnlock() + return r.observer +} + +// IsEmpty returns true when the router has no SNI routes and no fallback. +func (r *Router) IsEmpty() bool { + r.mu.RLock() + defer r.mu.RUnlock() + return len(r.routes) == 0 && r.fallback == nil +} + +// Serve accepts connections from ln and routes them based on SNI. +// It blocks until ctx is canceled or ln is closed, then drains +// active relay connections up to DefaultDrainTimeout. +func (r *Router) Serve(ctx context.Context, ln net.Listener) error { + done := make(chan struct{}) + defer close(done) + + go func() { + select { + case <-ctx.Done(): + _ = ln.Close() + if r.httpListener != nil { + r.httpListener.Close() + } + case <-done: + } + }() + + for { + conn, err := ln.Accept() + if err != nil { + if ctx.Err() != nil || errors.Is(err, net.ErrClosed) { + if ok := r.Drain(DefaultDrainTimeout); !ok { + r.logger.Warn("timed out waiting for connections to drain") + } + return nil + } + r.logger.Debugf("SNI router accept: %v", err) + continue + } + r.activeConns.Add(1) + go func() { + defer r.activeConns.Done() + r.handleConn(ctx, conn) + }() + } +} + +// handleConn peeks at the TLS ClientHello and routes the connection. +func (r *Router) handleConn(ctx context.Context, conn net.Conn) { + // Fast path: when no SNI routes and no HTTP channel exist (pure TCP + // fallback port), skip the TLS peek entirely to avoid read errors on + // non-TLS connections and reduce latency. + if r.isFallbackOnly() { + r.handleUnmatched(ctx, conn) + return + } + + if err := conn.SetReadDeadline(time.Now().Add(sniPeekTimeout)); err != nil { + r.logger.Debugf("set SNI peek deadline: %v", err) + _ = conn.Close() + return + } + + sni, wrapped, err := PeekClientHello(conn) + if err != nil { + r.logger.Debugf("SNI peek: %v", err) + if wrapped != nil { + r.handleUnmatched(ctx, wrapped) + } else { + _ = conn.Close() + } + return + } + + if err := wrapped.SetReadDeadline(time.Time{}); err != nil { + r.logger.Debugf("clear SNI peek deadline: %v", err) + _ = wrapped.Close() + return + } + + host := SNIHost(strings.ToLower(sni)) + route, ok := r.lookupRoute(host) + if !ok { + r.handleUnmatched(ctx, wrapped) + return + } + + if route.Type == RouteHTTP { + r.sendToHTTP(wrapped) + return + } + + if err := r.relayTCP(ctx, wrapped, host, route); err != nil { + if !errors.Is(err, errAccessRestricted) { + r.logger.WithFields(log.Fields{ + "sni": host, + "service_id": route.ServiceID, + "target": route.Target, + }).Warnf("TCP relay: %v", err) + } + _ = wrapped.Close() + } +} + +// isFallbackOnly returns true when the router has no SNI routes and no HTTP +// channel, meaning all connections should go directly to the fallback relay. +func (r *Router) isFallbackOnly() bool { + r.mu.RLock() + defer r.mu.RUnlock() + return len(r.routes) == 0 && r.httpCh == nil +} + +// handleUnmatched routes a connection that didn't match any SNI route. +// This includes ECH/ESNI connections where the cleartext SNI is empty. +// It tries the fallback relay first, then the HTTP channel, and closes +// the connection if neither is available. +func (r *Router) handleUnmatched(ctx context.Context, conn net.Conn) { + r.mu.RLock() + fb := r.fallback + r.mu.RUnlock() + + if fb != nil { + if err := r.relayTCP(ctx, conn, SNIHost("fallback"), *fb); err != nil { + if !errors.Is(err, errAccessRestricted) { + r.logger.WithFields(log.Fields{ + "service_id": fb.ServiceID, + "target": fb.Target, + }).Warnf("TCP relay (fallback): %v", err) + } + _ = conn.Close() + } + return + } + r.sendToHTTP(conn) +} + +// lookupRoute returns the highest-priority route for the given SNI host. +// HTTP routes take precedence over TCP routes. +func (r *Router) lookupRoute(host SNIHost) (Route, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + routes, ok := r.routes[host] + if !ok || len(routes) == 0 { + return Route{}, false + } + best := routes[0] + for _, route := range routes[1:] { + if route.Type < best.Type { + best = route + } + } + return best, true +} + +// sendToHTTP feeds the connection to the HTTP handler via the channel. +// If no HTTP channel is configured (port router), the router is +// draining, or the channel is full, the connection is closed. +func (r *Router) sendToHTTP(conn net.Conn) { + if r.httpCh == nil { + _ = conn.Close() + return + } + + r.mu.RLock() + draining := r.draining + r.mu.RUnlock() + + if draining { + _ = conn.Close() + return + } + + select { + case r.httpCh <- conn: + default: + r.logger.Warnf("HTTP channel full, dropping connection from %s", conn.RemoteAddr()) + _ = conn.Close() + } +} + +// Drain prevents new relay connections from starting and waits for all +// in-flight connection handlers and active relays to finish, up to the +// given timeout. Returns true if all completed, false on timeout. +func (r *Router) Drain(timeout time.Duration) bool { + r.mu.Lock() + r.draining = true + if r.drainDone == nil { + done := make(chan struct{}) + go func() { + r.activeConns.Wait() + r.activeRelays.Wait() + close(done) + }() + r.drainDone = done + } + done := r.drainDone + r.mu.Unlock() + + select { + case <-done: + return true + case <-time.After(timeout): + return false + } +} + +// cancelServiceLocked cancels and removes the context for the given service, +// closing all its active relay connections. Must be called with mu held. +func (r *Router) cancelServiceLocked(svcID types.ServiceID) { + if cancel, ok := r.svcCancels[svcID]; ok { + cancel() + delete(r.svcCtxs, svcID) + delete(r.svcCancels, svcID) + } +} + +// SetGeo sets the geolocation lookup used for country-based restrictions. +func (r *Router) SetGeo(geo restrict.GeoResolver) { + r.mu.Lock() + defer r.mu.Unlock() + r.geo = geo +} + +// checkRestrictions evaluates the route's access filter against the +// connection's remote address. Returns Allow if the connection is +// permitted, or a deny verdict indicating the reason. +func (r *Router) checkRestrictions(conn net.Conn, route Route) restrict.Verdict { + if route.Filter == nil { + return restrict.Allow + } + + addr, err := addrFromConn(conn) + if err != nil { + r.logger.Debugf("cannot parse client address %s for restriction check, denying", conn.RemoteAddr()) + return restrict.DenyCIDR + } + + r.mu.RLock() + geo := r.geo + r.mu.RUnlock() + + return route.Filter.Check(addr, geo) +} + +// relayTCP sets up and runs a bidirectional TCP relay. +// The caller owns conn and must close it if this method returns an error. +// On success (nil error), both conn and backend are closed by the relay. +func (r *Router) relayTCP(ctx context.Context, conn net.Conn, sni SNIHost, route Route) error { + if verdict := r.checkRestrictions(conn, route); verdict != restrict.Allow { + r.logger.Debugf("connection from %s rejected by access restrictions: %s", conn.RemoteAddr(), verdict) + r.logL4Deny(route, conn, verdict) + return errAccessRestricted + } + + svcCtx, err := r.acquireRelay(ctx, route) + if err != nil { + return err + } + defer func() { + <-r.relaySem + r.activeRelays.Done() + }() + + backend, err := r.dialBackend(svcCtx, route) + if err != nil { + obs := r.getObserver() + if obs != nil { + obs.TCPRelayDialError(route.AccountID) + } + return err + } + + if route.ProxyProtocol { + if err := writeProxyProtoV2(conn, backend); err != nil { + _ = backend.Close() + return fmt.Errorf("write PROXY protocol header: %w", err) + } + } + + obs := r.getObserver() + if obs != nil { + obs.TCPRelayStarted(route.AccountID) + } + + entry := r.logger.WithFields(log.Fields{ + "sni": sni, + "service_id": route.ServiceID, + "target": route.Target, + }) + entry.Debug("TCP relay started") + + idleTimeout := route.SessionIdleTimeout + if idleTimeout <= 0 { + idleTimeout = DefaultIdleTimeout + } + + start := time.Now() + s2d, d2s := Relay(svcCtx, entry, conn, backend, idleTimeout) + elapsed := time.Since(start) + + if obs != nil { + obs.TCPRelayEnded(route.AccountID, elapsed, s2d, d2s) + } + entry.Debugf("TCP relay ended (client→backend: %d bytes, backend→client: %d bytes)", s2d, d2s) + + r.logL4Entry(route, conn, elapsed, s2d, d2s) + return nil +} + +// acquireRelay checks draining state, increments activeRelays, and acquires +// a semaphore slot. Returns the per-service context on success. +// The caller must release the semaphore and call activeRelays.Done() when done. +func (r *Router) acquireRelay(ctx context.Context, route Route) (context.Context, error) { + r.mu.Lock() + if r.draining { + r.mu.Unlock() + return nil, errors.New("router is draining") + } + r.activeRelays.Add(1) + svcCtx := r.getOrCreateServiceCtxLocked(ctx, route.ServiceID) + r.mu.Unlock() + + select { + case r.relaySem <- struct{}{}: + return svcCtx, nil + default: + r.activeRelays.Done() + obs := r.getObserver() + if obs != nil { + obs.TCPRelayRejected(route.AccountID) + } + return nil, errors.New("TCP relay connection limit reached") + } +} + +// dialBackend resolves the dialer for the route's account and dials the backend. +func (r *Router) dialBackend(svcCtx context.Context, route Route) (net.Conn, error) { + dialFn, err := r.dialResolve(route.AccountID) + if err != nil { + return nil, fmt.Errorf("resolve dialer: %w", err) + } + + dialTimeout := route.DialTimeout + if dialTimeout <= 0 { + dialTimeout = defaultDialTimeout + } + dialCtx, dialCancel := context.WithTimeout(svcCtx, dialTimeout) + backend, err := dialFn(dialCtx, "tcp", route.Target) + dialCancel() + if err != nil { + return nil, fmt.Errorf("dial backend %s: %w", route.Target, err) + } + return backend, nil +} + +// logL4Entry sends a TCP relay access log entry if an access logger is configured. +func (r *Router) logL4Entry(route Route, conn net.Conn, duration time.Duration, bytesUp, bytesDown int64) { + r.mu.RLock() + al := r.accessLog + r.mu.RUnlock() + + if al == nil { + return + } + + sourceIP, _ := addrFromConn(conn) + + al.LogL4(accesslog.L4Entry{ + AccountID: route.AccountID, + ServiceID: route.ServiceID, + Protocol: route.Protocol, + Host: route.Domain, + SourceIP: sourceIP, + DurationMs: duration.Milliseconds(), + BytesUpload: bytesUp, + BytesDownload: bytesDown, + }) +} + +// logL4Deny sends an access log entry for a denied connection. +func (r *Router) logL4Deny(route Route, conn net.Conn, verdict restrict.Verdict) { + r.mu.RLock() + al := r.accessLog + r.mu.RUnlock() + + if al == nil { + return + } + + sourceIP, _ := addrFromConn(conn) + + al.LogL4(accesslog.L4Entry{ + AccountID: route.AccountID, + ServiceID: route.ServiceID, + Protocol: route.Protocol, + Host: route.Domain, + SourceIP: sourceIP, + DenyReason: verdict.String(), + }) +} + +// getOrCreateServiceCtxLocked returns the context for a service, creating one +// if it doesn't exist yet. The context is a child of the server context. +// Must be called with mu held. +func (r *Router) getOrCreateServiceCtxLocked(parent context.Context, svcID types.ServiceID) context.Context { + if ctx, ok := r.svcCtxs[svcID]; ok { + return ctx + } + ctx, cancel := context.WithCancel(parent) + r.svcCtxs[svcID] = ctx + r.svcCancels[svcID] = cancel + return ctx +} + +// addrFromConn extracts a netip.Addr from a connection's remote address. +func addrFromConn(conn net.Conn) (netip.Addr, error) { + remote := conn.RemoteAddr() + if remote == nil { + return netip.Addr{}, errors.New("no remote address") + } + ap, err := netip.ParseAddrPort(remote.String()) + if err != nil { + return netip.Addr{}, err + } + return ap.Addr().Unmap(), nil +} diff --git a/proxy/internal/tcp/router_test.go b/proxy/internal/tcp/router_test.go new file mode 100644 index 000000000..189cdc622 --- /dev/null +++ b/proxy/internal/tcp/router_test.go @@ -0,0 +1,1741 @@ +package tcp + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "math/big" + "net" + "testing" + "time" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/restrict" + "github.com/netbirdio/netbird/proxy/internal/types" +) + +func TestRouter_HTTPRouting(t *testing.T) { + logger := log.StandardLogger() + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443} + + router := NewRouter(logger, nil, addr) + router.AddRoute("example.com", Route{Type: RouteHTTP}) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + _ = router.Serve(ctx, ln) + }() + + // Dial in a goroutine. The TLS handshake will block since nothing + // completes it on the HTTP side, but we only care about routing. + go func() { + conn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + if err != nil { + return + } + // Send a TLS ClientHello manually. + tlsConn := tls.Client(conn, &tls.Config{ + ServerName: "example.com", + InsecureSkipVerify: true, //nolint:gosec + }) + _ = tlsConn.Handshake() + tlsConn.Close() + }() + + // Verify the connection was routed to the HTTP channel. + select { + case conn := <-router.httpCh: + assert.NotNil(t, conn) + conn.Close() + case <-time.After(5 * time.Second): + t.Fatal("no connection received on HTTP channel") + } +} + +func TestRouter_TCPRouting(t *testing.T) { + logger := log.StandardLogger() + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443} + + // Set up a TLS backend that the relay will connect to. + backendCert := generateSelfSignedCert(t) + backendLn, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{ + Certificates: []tls.Certificate{backendCert}, + }) + require.NoError(t, err) + defer backendLn.Close() + + backendAddr := backendLn.Addr().String() + + // Accept one connection on the backend, echo data back. + backendReady := make(chan struct{}) + go func() { + close(backendReady) + conn, err := backendLn.Accept() + if err != nil { + return + } + defer conn.Close() + buf := make([]byte, 1024) + n, _ := conn.Read(buf) + _, _ = conn.Write(buf[:n]) + }() + <-backendReady + + dialResolve := func(accountID types.AccountID) (types.DialContextFunc, error) { + return func(ctx context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + }, nil + } + + router := NewRouter(logger, dialResolve, addr) + router.AddRoute("tcp.example.com", Route{ + Type: RouteTCP, + AccountID: "test-account", + ServiceID: "test-service", + Target: backendAddr, + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + _ = router.Serve(ctx, ln) + }() + + // Connect as a TLS client; the proxy should passthrough to the backend. + clientConn, err := tls.Dial("tcp", ln.Addr().String(), &tls.Config{ + ServerName: "tcp.example.com", + InsecureSkipVerify: true, //nolint:gosec + }) + require.NoError(t, err) + defer clientConn.Close() + + testData := []byte("hello through TCP passthrough") + _, err = clientConn.Write(testData) + require.NoError(t, err) + + buf := make([]byte, 1024) + n, err := clientConn.Read(buf) + require.NoError(t, err) + assert.Equal(t, testData, buf[:n], "should receive echoed data through TCP passthrough") +} + +func TestRouter_UnknownSNIGoesToHTTP(t *testing.T) { + logger := log.StandardLogger() + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443} + + router := NewRouter(logger, nil, addr) + // No routes registered. + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + _ = router.Serve(ctx, ln) + }() + + go func() { + conn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + if err != nil { + return + } + tlsConn := tls.Client(conn, &tls.Config{ + ServerName: "unknown.example.com", + InsecureSkipVerify: true, //nolint:gosec + }) + _ = tlsConn.Handshake() + tlsConn.Close() + }() + + select { + case conn := <-router.httpCh: + assert.NotNil(t, conn) + conn.Close() + case <-time.After(5 * time.Second): + t.Fatal("unknown SNI should be routed to HTTP") + } +} + +// TestRouter_NonTLSConnectionDropped verifies that a non-TLS connection +// on the shared port is closed by the router (SNI peek fails to find a +// valid ClientHello, so there is no route match). +func TestRouter_NonTLSConnectionDropped(t *testing.T) { + logger := log.StandardLogger() + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443} + + // Register a TLS passthrough route. Non-TLS should NOT match. + dialResolve := func(accountID types.AccountID) (types.DialContextFunc, error) { + return func(ctx context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + }, nil + } + + router := NewRouter(logger, dialResolve, addr) + router.AddRoute("tcp.example.com", Route{ + Type: RouteTCP, + AccountID: "test-account", + ServiceID: "test-service", + Target: "127.0.0.1:9999", + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + _ = router.Serve(ctx, ln) + }() + + // Send plain HTTP (non-TLS) data. + conn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + require.NoError(t, err) + defer conn.Close() + + _, _ = conn.Write([]byte("GET / HTTP/1.1\r\nHost: tcp.example.com\r\n\r\n")) + + // Non-TLS traffic on a port with RouteTCP goes to the HTTP channel + // because there's no valid SNI to match. Verify it reaches HTTP. + select { + case httpConn := <-router.httpCh: + assert.NotNil(t, httpConn, "non-TLS connection should fall through to HTTP") + httpConn.Close() + case <-time.After(5 * time.Second): + t.Fatal("non-TLS connection was not routed to HTTP") + } +} + +// TestRouter_TLSAndHTTPCoexist verifies that a shared port with both HTTP +// and TLS passthrough routes correctly demuxes based on the SNI hostname. +func TestRouter_TLSAndHTTPCoexist(t *testing.T) { + logger := log.StandardLogger() + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443} + + backendCert := generateSelfSignedCert(t) + backendLn, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{ + Certificates: []tls.Certificate{backendCert}, + }) + require.NoError(t, err) + defer backendLn.Close() + + // Backend echoes data. + go func() { + conn, err := backendLn.Accept() + if err != nil { + return + } + defer conn.Close() + buf := make([]byte, 1024) + n, _ := conn.Read(buf) + _, _ = conn.Write(buf[:n]) + }() + + dialResolve := func(accountID types.AccountID) (types.DialContextFunc, error) { + return func(ctx context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + }, nil + } + + router := NewRouter(logger, dialResolve, addr) + // HTTP route. + router.AddRoute("app.example.com", Route{Type: RouteHTTP}) + // TLS passthrough route. + router.AddRoute("tcp.example.com", Route{ + Type: RouteTCP, + AccountID: "test-account", + ServiceID: "test-service", + Target: backendLn.Addr().String(), + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + _ = router.Serve(ctx, ln) + }() + + // 1. TLS connection with SNI "tcp.example.com" → TLS passthrough. + tlsConn, err := tls.Dial("tcp", ln.Addr().String(), &tls.Config{ + ServerName: "tcp.example.com", + InsecureSkipVerify: true, //nolint:gosec + }) + require.NoError(t, err) + + testData := []byte("passthrough data") + _, err = tlsConn.Write(testData) + require.NoError(t, err) + buf := make([]byte, 1024) + n, err := tlsConn.Read(buf) + require.NoError(t, err) + assert.Equal(t, testData, buf[:n], "TLS passthrough should relay data") + tlsConn.Close() + + // 2. TLS connection with SNI "app.example.com" → HTTP handler. + go func() { + conn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + if err != nil { + return + } + c := tls.Client(conn, &tls.Config{ + ServerName: "app.example.com", + InsecureSkipVerify: true, //nolint:gosec + }) + _ = c.Handshake() + c.Close() + }() + + select { + case httpConn := <-router.httpCh: + assert.NotNil(t, httpConn, "HTTP SNI should go to HTTP handler") + httpConn.Close() + case <-time.After(5 * time.Second): + t.Fatal("HTTP-route connection was not delivered to HTTP handler") + } +} + +func TestRouter_AddRemoveRoute(t *testing.T) { + logger := log.StandardLogger() + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443} + router := NewRouter(logger, nil, addr) + + router.AddRoute("a.example.com", Route{Type: RouteHTTP, ServiceID: "svc-a"}) + router.AddRoute("b.example.com", Route{Type: RouteTCP, ServiceID: "svc-b", Target: "10.0.0.1:5432"}) + + route, ok := router.lookupRoute("a.example.com") + assert.True(t, ok) + assert.Equal(t, RouteHTTP, route.Type) + + route, ok = router.lookupRoute("b.example.com") + assert.True(t, ok) + assert.Equal(t, RouteTCP, route.Type) + + router.RemoveRoute("a.example.com", "svc-a") + _, ok = router.lookupRoute("a.example.com") + assert.False(t, ok) +} + +func TestChanListener_AcceptAndClose(t *testing.T) { + ch := make(chan net.Conn, 1) + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443} + ln := newChanListener(ch, addr) + + assert.Equal(t, addr, ln.Addr()) + + // Send a connection. + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + + ch <- serverConn + + conn, err := ln.Accept() + require.NoError(t, err) + assert.Equal(t, serverConn, conn) + + // Close should cause Accept to return error. + require.NoError(t, ln.Close()) + // Double close should be safe. + require.NoError(t, ln.Close()) + + _, err = ln.Accept() + assert.ErrorIs(t, err, net.ErrClosed) +} + +func TestRouter_HTTPPrecedenceGuard(t *testing.T) { + logger := log.StandardLogger() + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443} + router := NewRouter(logger, nil, addr) + + host := SNIHost("app.example.com") + + t.Run("http takes precedence over tcp at lookup", func(t *testing.T) { + router.AddRoute(host, Route{Type: RouteHTTP, ServiceID: "svc-http"}) + router.AddRoute(host, Route{Type: RouteTCP, ServiceID: "svc-tcp", Target: "10.0.0.1:443"}) + + route, ok := router.lookupRoute(host) + require.True(t, ok) + assert.Equal(t, RouteHTTP, route.Type, "HTTP route must take precedence over TCP") + assert.Equal(t, types.ServiceID("svc-http"), route.ServiceID) + + router.RemoveRoute(host, "svc-http") + router.RemoveRoute(host, "svc-tcp") + }) + + t.Run("tcp becomes active when http is removed", func(t *testing.T) { + router.AddRoute(host, Route{Type: RouteHTTP, ServiceID: "svc-http"}) + router.AddRoute(host, Route{Type: RouteTCP, ServiceID: "svc-tcp", Target: "10.0.0.1:443"}) + + router.RemoveRoute(host, "svc-http") + + route, ok := router.lookupRoute(host) + require.True(t, ok) + assert.Equal(t, RouteTCP, route.Type, "TCP should take over after HTTP removal") + assert.Equal(t, types.ServiceID("svc-tcp"), route.ServiceID) + + router.RemoveRoute(host, "svc-tcp") + }) + + t.Run("order of add does not matter", func(t *testing.T) { + router.AddRoute(host, Route{Type: RouteTCP, ServiceID: "svc-tcp", Target: "10.0.0.1:443"}) + router.AddRoute(host, Route{Type: RouteHTTP, ServiceID: "svc-http"}) + + route, ok := router.lookupRoute(host) + require.True(t, ok) + assert.Equal(t, RouteHTTP, route.Type, "HTTP takes precedence regardless of add order") + + router.RemoveRoute(host, "svc-http") + router.RemoveRoute(host, "svc-tcp") + }) + + t.Run("same service id updates in place", func(t *testing.T) { + router.AddRoute(host, Route{Type: RouteTCP, ServiceID: "svc-1", Target: "10.0.0.1:443"}) + router.AddRoute(host, Route{Type: RouteTCP, ServiceID: "svc-1", Target: "10.0.0.2:443"}) + + route, ok := router.lookupRoute(host) + require.True(t, ok) + assert.Equal(t, "10.0.0.2:443", route.Target, "route should be updated in place") + + router.RemoveRoute(host, "svc-1") + _, ok = router.lookupRoute(host) + assert.False(t, ok) + }) + + t.Run("double remove is safe", func(t *testing.T) { + router.AddRoute(host, Route{Type: RouteHTTP, ServiceID: "svc-1"}) + router.RemoveRoute(host, "svc-1") + router.RemoveRoute(host, "svc-1") + + _, ok := router.lookupRoute(host) + assert.False(t, ok, "route should be gone after removal") + }) + + t.Run("remove does not affect other hosts", func(t *testing.T) { + router.AddRoute("a.example.com", Route{Type: RouteHTTP, ServiceID: "svc-a"}) + router.AddRoute("b.example.com", Route{Type: RouteTCP, ServiceID: "svc-b", Target: "10.0.0.2:22"}) + + router.RemoveRoute("a.example.com", "svc-a") + + _, ok := router.lookupRoute(SNIHost("a.example.com")) + assert.False(t, ok) + + route, ok := router.lookupRoute(SNIHost("b.example.com")) + require.True(t, ok) + assert.Equal(t, RouteTCP, route.Type, "removing one host must not affect another") + + router.RemoveRoute("b.example.com", "svc-b") + }) +} + +func TestRouter_SetRemoveFallback(t *testing.T) { + logger := log.StandardLogger() + router := NewPortRouter(logger, nil) + + assert.True(t, router.IsEmpty(), "new port router should be empty") + + router.SetFallback(Route{Type: RouteTCP, ServiceID: "svc-fb", Target: "10.0.0.1:5432"}) + assert.False(t, router.IsEmpty(), "router with fallback should not be empty") + + router.AddRoute("a.example.com", Route{Type: RouteTCP, ServiceID: "svc-a", Target: "10.0.0.2:443"}) + assert.False(t, router.IsEmpty()) + + router.RemoveFallback("svc-fb") + assert.False(t, router.IsEmpty(), "router with SNI route should not be empty") + + router.RemoveRoute("a.example.com", "svc-a") + assert.True(t, router.IsEmpty(), "router with no routes and no fallback should be empty") +} + +func TestPortRouter_FallbackRelaysData(t *testing.T) { + // Backend echo server. + backendLn, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer backendLn.Close() + + go func() { + conn, err := backendLn.Accept() + if err != nil { + return + } + defer conn.Close() + buf := make([]byte, 1024) + n, _ := conn.Read(buf) + _, _ = conn.Write(buf[:n]) + }() + + dialResolve := func(_ types.AccountID) (types.DialContextFunc, error) { + return func(_ context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + }, nil + } + + logger := log.StandardLogger() + router := NewPortRouter(logger, dialResolve) + router.SetFallback(Route{ + Type: RouteTCP, + AccountID: "test-account", + ServiceID: "test-service", + Target: backendLn.Addr().String(), + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { _ = router.Serve(ctx, ln) }() + + // Plain TCP (non-TLS) connection should be relayed via fallback. + // Use exactly 5 bytes. PeekClientHello reads 5 bytes as the TLS + // header, so a single 5-byte write lands as one chunk at the backend. + conn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + require.NoError(t, err) + defer conn.Close() + + testData := []byte("hello") + _, err = conn.Write(testData) + require.NoError(t, err) + + buf := make([]byte, 1024) + n, err := conn.Read(buf) + require.NoError(t, err) + assert.Equal(t, testData, buf[:n], "should receive echoed data through fallback relay") +} + +func TestPortRouter_FallbackOnUnknownSNI(t *testing.T) { + // Backend TLS echo server. + backendCert := generateSelfSignedCert(t) + backendLn, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{ + Certificates: []tls.Certificate{backendCert}, + }) + require.NoError(t, err) + defer backendLn.Close() + + go func() { + conn, err := backendLn.Accept() + if err != nil { + return + } + defer conn.Close() + buf := make([]byte, 1024) + n, _ := conn.Read(buf) + _, _ = conn.Write(buf[:n]) + }() + + dialResolve := func(_ types.AccountID) (types.DialContextFunc, error) { + return func(_ context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + }, nil + } + + logger := log.StandardLogger() + router := NewPortRouter(logger, dialResolve) + // Only a fallback, no SNI route for "unknown.example.com". + router.SetFallback(Route{ + Type: RouteTCP, + AccountID: "test-account", + ServiceID: "test-service", + Target: backendLn.Addr().String(), + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { _ = router.Serve(ctx, ln) }() + + // TLS with unknown SNI → fallback relay to TLS backend. + tlsConn, err := tls.Dial("tcp", ln.Addr().String(), &tls.Config{ + ServerName: "tcp.example.com", + InsecureSkipVerify: true, //nolint:gosec + }) + require.NoError(t, err) + defer tlsConn.Close() + + testData := []byte("hello through fallback TLS") + _, err = tlsConn.Write(testData) + require.NoError(t, err) + + buf := make([]byte, 1024) + n, err := tlsConn.Read(buf) + require.NoError(t, err) + assert.Equal(t, testData, buf[:n], "unknown SNI should relay through fallback") +} + +func TestPortRouter_SNIWinsOverFallback(t *testing.T) { + // Two backend echo servers: one for SNI match, one for fallback. + sniBacked := startEchoTLS(t) + fbBacked := startEchoTLS(t) + + dialResolve := func(_ types.AccountID) (types.DialContextFunc, error) { + return func(_ context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + }, nil + } + + logger := log.StandardLogger() + router := NewPortRouter(logger, dialResolve) + router.AddRoute("tcp.example.com", Route{ + Type: RouteTCP, + AccountID: "test-account", + ServiceID: "sni-service", + Target: sniBacked.Addr().String(), + }) + router.SetFallback(Route{ + Type: RouteTCP, + AccountID: "test-account", + ServiceID: "fb-service", + Target: fbBacked.Addr().String(), + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { _ = router.Serve(ctx, ln) }() + + // TLS with matching SNI should go to SNI backend, not fallback. + tlsConn, err := tls.Dial("tcp", ln.Addr().String(), &tls.Config{ + ServerName: "tcp.example.com", + InsecureSkipVerify: true, //nolint:gosec + }) + require.NoError(t, err) + defer tlsConn.Close() + + testData := []byte("SNI route data") + _, err = tlsConn.Write(testData) + require.NoError(t, err) + + buf := make([]byte, 1024) + n, err := tlsConn.Read(buf) + require.NoError(t, err) + assert.Equal(t, testData, buf[:n], "SNI match should use SNI route, not fallback") +} + +func TestPortRouter_NoFallbackNoHTTP_Closes(t *testing.T) { + logger := log.StandardLogger() + router := NewPortRouter(logger, nil) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { _ = router.Serve(ctx, ln) }() + + conn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + require.NoError(t, err) + defer conn.Close() + + _, _ = conn.Write([]byte("hello")) + + // Connection should be closed by the router (no fallback, no HTTP). + buf := make([]byte, 1) + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, err = conn.Read(buf) + assert.Error(t, err, "connection should be closed when no fallback and no HTTP channel") +} + +func TestRouter_FallbackAndHTTPCoexist(t *testing.T) { + // Fallback backend echo server (plain TCP). + fbBackend, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer fbBackend.Close() + + go func() { + conn, err := fbBackend.Accept() + if err != nil { + return + } + defer conn.Close() + buf := make([]byte, 1024) + n, _ := conn.Read(buf) + _, _ = conn.Write(buf[:n]) + }() + + dialResolve := func(_ types.AccountID) (types.DialContextFunc, error) { + return func(_ context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + }, nil + } + + logger := log.StandardLogger() + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443} + router := NewRouter(logger, dialResolve, addr) + + // HTTP route for known SNI. + router.AddRoute("app.example.com", Route{Type: RouteHTTP}) + // Fallback for non-TLS / unknown SNI. + router.SetFallback(Route{ + Type: RouteTCP, + AccountID: "test-account", + ServiceID: "fb-service", + Target: fbBackend.Addr().String(), + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { _ = router.Serve(ctx, ln) }() + + // 1. TLS with known HTTP SNI → should go to HTTP channel. + go func() { + conn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + if err != nil { + return + } + c := tls.Client(conn, &tls.Config{ + ServerName: "app.example.com", + InsecureSkipVerify: true, //nolint:gosec + }) + _ = c.Handshake() + c.Close() + }() + + select { + case httpConn := <-router.httpCh: + assert.NotNil(t, httpConn, "known HTTP SNI should go to HTTP channel") + httpConn.Close() + case <-time.After(5 * time.Second): + t.Fatal("HTTP-route connection was not delivered to HTTP handler") + } + + // 2. Plain TCP (non-TLS) → should go to fallback, not HTTP. + // Use exactly 5 bytes to match PeekClientHello header size. + conn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + require.NoError(t, err) + defer conn.Close() + + testData := []byte("plain") + _, err = conn.Write(testData) + require.NoError(t, err) + + buf := make([]byte, 1024) + n, err := conn.Read(buf) + require.NoError(t, err) + assert.Equal(t, testData, buf[:n], "non-TLS should be relayed via fallback, not HTTP") +} + +// startEchoTLS starts a TLS echo server and returns the listener. +func startEchoTLS(t *testing.T) net.Listener { + t.Helper() + + cert := generateSelfSignedCert(t) + ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{ + Certificates: []tls.Certificate{cert}, + }) + require.NoError(t, err) + t.Cleanup(func() { ln.Close() }) + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer conn.Close() + buf := make([]byte, 1024) + for { + n, err := conn.Read(buf) + if err != nil { + return + } + if _, err := conn.Write(buf[:n]); err != nil { + return + } + } + }() + + return ln +} + +func generateSelfSignedCert(t *testing.T) tls.Certificate { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + DNSNames: []string{"tcp.example.com"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + + return tls.Certificate{ + Certificate: [][]byte{certDER}, + PrivateKey: key, + } +} + +func TestRouter_DrainWaitsForRelays(t *testing.T) { + logger := log.StandardLogger() + backendLn, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer backendLn.Close() + + // Accept connections: echo first message, then hold open until told to close. + closeBackend := make(chan struct{}) + go func() { + for { + conn, err := backendLn.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer c.Close() + buf := make([]byte, 1024) + n, _ := c.Read(buf) + _, _ = c.Write(buf[:n]) + <-closeBackend + }(conn) + } + }() + + dialResolve := func(_ types.AccountID) (types.DialContextFunc, error) { + return (&net.Dialer{}).DialContext, nil + } + + router := NewPortRouter(logger, dialResolve) + router.SetFallback(Route{ + Type: RouteTCP, + Target: backendLn.Addr().String(), + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + serveDone := make(chan struct{}) + go func() { + _ = router.Serve(ctx, ln) + close(serveDone) + }() + + // Open a relay connection (non-TLS, hits fallback). + conn, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + _, _ = conn.Write([]byte("hello")) + + // Wait for the echo to confirm the relay is fully established. + buf := make([]byte, 16) + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + n, err := conn.Read(buf) + require.NoError(t, err) + assert.Equal(t, "hello", string(buf[:n])) + _ = conn.SetReadDeadline(time.Time{}) + + // Drain with a short timeout should fail because the relay is still active. + assert.False(t, router.Drain(50*time.Millisecond), "drain should timeout with active relay") + + // Close backend connections so relays finish. + close(closeBackend) + _ = conn.Close() + + // Drain should now complete quickly. + assert.True(t, router.Drain(2*time.Second), "drain should succeed after relays end") + + cancel() + <-serveDone +} + +func TestRouter_DrainEmptyReturnsImmediately(t *testing.T) { + logger := log.StandardLogger() + router := NewPortRouter(logger, nil) + + start := time.Now() + ok := router.Drain(5 * time.Second) + elapsed := time.Since(start) + + assert.True(t, ok) + assert.Less(t, elapsed, 100*time.Millisecond, "drain with no relays should return immediately") +} + +// TestRemoveRoute_KillsActiveRelays verifies that removing a route +// immediately kills active relay connections for that service. +func TestRemoveRoute_KillsActiveRelays(t *testing.T) { + backendLn, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer backendLn.Close() + + // Backend echoes first message, then holds connection open. + go func() { + for { + conn, err := backendLn.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer c.Close() + buf := make([]byte, 1024) + n, _ := c.Read(buf) + _, _ = c.Write(buf[:n]) + // Hold the connection open. + for { + if _, err := c.Read(buf); err != nil { + return + } + } + }(conn) + } + }() + + dialResolve := func(_ types.AccountID) (types.DialContextFunc, error) { + return (&net.Dialer{}).DialContext, nil + } + + logger := log.StandardLogger() + router := NewPortRouter(logger, dialResolve) + router.SetFallback(Route{ + Type: RouteTCP, + ServiceID: "svc-1", + Target: backendLn.Addr().String(), + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { _ = router.Serve(ctx, ln) }() + + // Establish a relay connection. + conn, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + defer conn.Close() + _, err = conn.Write([]byte("hello")) + require.NoError(t, err) + + // Wait for echo to confirm relay is established. + buf := make([]byte, 16) + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + n, err := conn.Read(buf) + require.NoError(t, err) + assert.Equal(t, "hello", string(buf[:n])) + _ = conn.SetReadDeadline(time.Time{}) + + // Remove the fallback: should kill the active relay. + router.RemoveFallback("svc-1") + + // The client connection should see an error (server closed). + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, err = conn.Read(buf) + assert.Error(t, err, "connection should be killed after service removal") +} + +// TestRemoveRoute_KillsSNIRelays verifies that removing an SNI route +// kills its active relays without affecting other services. +func TestRemoveRoute_KillsSNIRelays(t *testing.T) { + backend := startEchoTLS(t) + defer backend.Close() + + dialResolve := func(_ types.AccountID) (types.DialContextFunc, error) { + return (&net.Dialer{}).DialContext, nil + } + + logger := log.StandardLogger() + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443} + router := NewRouter(logger, dialResolve, addr) + router.AddRoute("tls.example.com", Route{ + Type: RouteTCP, + ServiceID: "svc-tls", + Target: backend.Addr().String(), + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { _ = router.Serve(ctx, ln) }() + + // Establish a TLS relay. + tlsConn, err := tls.DialWithDialer( + &net.Dialer{Timeout: 2 * time.Second}, + "tcp", ln.Addr().String(), + &tls.Config{ServerName: "tls.example.com", InsecureSkipVerify: true}, + ) + require.NoError(t, err) + defer tlsConn.Close() + + _, err = tlsConn.Write([]byte("ping")) + require.NoError(t, err) + buf := make([]byte, 1024) + n, err := tlsConn.Read(buf) + require.NoError(t, err) + assert.Equal(t, "ping", string(buf[:n])) + + // Remove the route: active relay should die. + router.RemoveRoute("tls.example.com", "svc-tls") + + _ = tlsConn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, err = tlsConn.Read(buf) + assert.Error(t, err, "TLS relay should be killed after route removal") +} + +// TestPortRouter_SNIAndTCPFallbackCoexist verifies that a single port can +// serve both SNI-routed TLS passthrough and plain TCP fallback simultaneously. +func TestPortRouter_SNIAndTCPFallbackCoexist(t *testing.T) { + sniBackend := startEchoTLS(t) + fbBackend := startEchoPlain(t) + + dialResolve := func(_ types.AccountID) (types.DialContextFunc, error) { + return func(_ context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + }, nil + } + + logger := log.StandardLogger() + router := NewPortRouter(logger, dialResolve) + + // SNI route for a specific domain. + router.AddRoute("tcp.example.com", Route{ + Type: RouteTCP, + AccountID: "acct-1", + ServiceID: "svc-sni", + Target: sniBackend.Addr().String(), + }) + // TCP fallback for everything else. + router.SetFallback(Route{ + Type: RouteTCP, + AccountID: "acct-2", + ServiceID: "svc-fb", + Target: fbBackend.Addr().String(), + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { _ = router.Serve(ctx, ln) }() + + // 1. TLS with matching SNI → goes to SNI backend. + tlsConn, err := tls.Dial("tcp", ln.Addr().String(), &tls.Config{ + ServerName: "tcp.example.com", + InsecureSkipVerify: true, //nolint:gosec + }) + require.NoError(t, err) + + _, err = tlsConn.Write([]byte("sni-data")) + require.NoError(t, err) + buf := make([]byte, 1024) + n, err := tlsConn.Read(buf) + require.NoError(t, err) + assert.Equal(t, "sni-data", string(buf[:n]), "SNI match → SNI backend") + tlsConn.Close() + + // 2. Plain TCP (no TLS) → goes to fallback. + tcpConn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + require.NoError(t, err) + + _, err = tcpConn.Write([]byte("plain")) + require.NoError(t, err) + n, err = tcpConn.Read(buf) + require.NoError(t, err) + assert.Equal(t, "plain", string(buf[:n]), "plain TCP → fallback backend") + tcpConn.Close() + + // 3. TLS with unknown SNI → also goes to fallback. + unknownBackend := startEchoTLS(t) + router.SetFallback(Route{ + Type: RouteTCP, + AccountID: "acct-2", + ServiceID: "svc-fb", + Target: unknownBackend.Addr().String(), + }) + + unknownTLS, err := tls.Dial("tcp", ln.Addr().String(), &tls.Config{ + ServerName: "unknown.example.com", + InsecureSkipVerify: true, //nolint:gosec + }) + require.NoError(t, err) + + _, err = unknownTLS.Write([]byte("unknown-sni")) + require.NoError(t, err) + n, err = unknownTLS.Read(buf) + require.NoError(t, err) + assert.Equal(t, "unknown-sni", string(buf[:n]), "unknown SNI → fallback backend") + unknownTLS.Close() +} + +// TestPortRouter_UpdateRouteSwapsSNI verifies that updating a route +// (remove + add with different target) correctly routes to the new backend. +func TestPortRouter_UpdateRouteSwapsSNI(t *testing.T) { + backend1 := startEchoTLS(t) + backend2 := startEchoTLS(t) + + dialResolve := func(_ types.AccountID) (types.DialContextFunc, error) { + return func(_ context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + }, nil + } + + logger := log.StandardLogger() + router := NewPortRouter(logger, dialResolve) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { _ = router.Serve(ctx, ln) }() + + // Initial route → backend1. + router.AddRoute("db.example.com", Route{ + Type: RouteTCP, + ServiceID: "svc-db", + Target: backend1.Addr().String(), + }) + + conn1, err := tls.Dial("tcp", ln.Addr().String(), &tls.Config{ + ServerName: "db.example.com", + InsecureSkipVerify: true, //nolint:gosec + }) + require.NoError(t, err) + _, err = conn1.Write([]byte("v1")) + require.NoError(t, err) + buf := make([]byte, 1024) + n, err := conn1.Read(buf) + require.NoError(t, err) + assert.Equal(t, "v1", string(buf[:n])) + conn1.Close() + + // Update: remove old route, add new → backend2. + router.RemoveRoute("db.example.com", "svc-db") + router.AddRoute("db.example.com", Route{ + Type: RouteTCP, + ServiceID: "svc-db", + Target: backend2.Addr().String(), + }) + + conn2, err := tls.Dial("tcp", ln.Addr().String(), &tls.Config{ + ServerName: "db.example.com", + InsecureSkipVerify: true, //nolint:gosec + }) + require.NoError(t, err) + _, err = conn2.Write([]byte("v2")) + require.NoError(t, err) + n, err = conn2.Read(buf) + require.NoError(t, err) + assert.Equal(t, "v2", string(buf[:n])) + conn2.Close() +} + +// TestPortRouter_RemoveSNIFallsThrough verifies that after removing an +// SNI route, connections for that domain fall through to the fallback. +func TestPortRouter_RemoveSNIFallsThrough(t *testing.T) { + sniBackend := startEchoTLS(t) + fbBackend := startEchoTLS(t) + + dialResolve := func(_ types.AccountID) (types.DialContextFunc, error) { + return func(_ context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + }, nil + } + + logger := log.StandardLogger() + router := NewPortRouter(logger, dialResolve) + router.AddRoute("db.example.com", Route{ + Type: RouteTCP, + ServiceID: "svc-db", + Target: sniBackend.Addr().String(), + }) + router.SetFallback(Route{ + Type: RouteTCP, + Target: fbBackend.Addr().String(), + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { _ = router.Serve(ctx, ln) }() + + // Before removal: SNI matches → sniBackend. + conn1, err := tls.Dial("tcp", ln.Addr().String(), &tls.Config{ + ServerName: "db.example.com", + InsecureSkipVerify: true, //nolint:gosec + }) + require.NoError(t, err) + _, err = conn1.Write([]byte("before")) + require.NoError(t, err) + buf := make([]byte, 1024) + n, err := conn1.Read(buf) + require.NoError(t, err) + assert.Equal(t, "before", string(buf[:n])) + conn1.Close() + + // Remove SNI route. Should fall through to fallback. + router.RemoveRoute("db.example.com", "svc-db") + + conn2, err := tls.Dial("tcp", ln.Addr().String(), &tls.Config{ + ServerName: "db.example.com", + InsecureSkipVerify: true, //nolint:gosec + }) + require.NoError(t, err) + _, err = conn2.Write([]byte("after")) + require.NoError(t, err) + n, err = conn2.Read(buf) + require.NoError(t, err) + assert.Equal(t, "after", string(buf[:n]), "after removal, should reach fallback") + conn2.Close() +} + +// TestPortRouter_RemoveFallbackCloses verifies that after removing the +// fallback, non-matching connections are closed. +func TestPortRouter_RemoveFallbackCloses(t *testing.T) { + fbBackend := startEchoPlain(t) + + dialResolve := func(_ types.AccountID) (types.DialContextFunc, error) { + return func(_ context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + }, nil + } + + logger := log.StandardLogger() + router := NewPortRouter(logger, dialResolve) + router.SetFallback(Route{ + Type: RouteTCP, + ServiceID: "svc-fb", + Target: fbBackend.Addr().String(), + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { _ = router.Serve(ctx, ln) }() + + // With fallback: plain TCP works. + conn1, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + require.NoError(t, err) + _, err = conn1.Write([]byte("hello")) + require.NoError(t, err) + buf := make([]byte, 1024) + n, err := conn1.Read(buf) + require.NoError(t, err) + assert.Equal(t, "hello", string(buf[:n])) + conn1.Close() + + // Remove fallback. + router.RemoveFallback("svc-fb") + + // Without fallback on a port router (no HTTP channel): connection should be closed. + conn2, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + require.NoError(t, err) + defer conn2.Close() + _, _ = conn2.Write([]byte("bye")) + _ = conn2.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, err = conn2.Read(buf) + assert.Error(t, err, "without fallback, connection should be closed") +} + +// TestPortRouter_HTTPToTLSTransition verifies that switching a service from +// HTTP-only to TLS-only via remove+add doesn't orphan the old HTTP route. +func TestPortRouter_HTTPToTLSTransition(t *testing.T) { + logger := log.StandardLogger() + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443} + tlsBackend := startEchoTLS(t) + + dialResolve := func(_ types.AccountID) (types.DialContextFunc, error) { + return (&net.Dialer{}).DialContext, nil + } + + router := NewRouter(logger, dialResolve, addr) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { _ = router.Serve(ctx, ln) }() + + // Phase 1: HTTP-only. SNI connections go to HTTP channel. + router.AddRoute("app.example.com", Route{Type: RouteHTTP, AccountID: "acct-1", ServiceID: "svc-1"}) + + httpConn := router.HTTPListener() + connDone := make(chan struct{}) + go func() { + defer close(connDone) + c, err := httpConn.Accept() + if err == nil { + c.Close() + } + }() + tlsConn, err := tls.DialWithDialer( + &net.Dialer{Timeout: 2 * time.Second}, + "tcp", ln.Addr().String(), + &tls.Config{ServerName: "app.example.com", InsecureSkipVerify: true}, + ) + if err == nil { + tlsConn.Close() + } + select { + case <-connDone: + case <-time.After(2 * time.Second): + t.Fatal("HTTP listener did not receive connection for HTTP-only route") + } + + // Phase 2: Simulate update to TLS-only (removeMapping + addMapping). + router.RemoveRoute("app.example.com", "svc-1") + router.AddRoute("app.example.com", Route{ + Type: RouteTCP, + AccountID: "acct-1", + ServiceID: "svc-1", + Target: tlsBackend.Addr().String(), + }) + + tlsConn2, err := tls.DialWithDialer( + &net.Dialer{Timeout: 2 * time.Second}, + "tcp", ln.Addr().String(), + &tls.Config{ServerName: "app.example.com", InsecureSkipVerify: true}, + ) + require.NoError(t, err, "TLS connection should succeed after HTTP→TLS transition") + defer tlsConn2.Close() + + _, err = tlsConn2.Write([]byte("hello-tls")) + require.NoError(t, err) + buf := make([]byte, 1024) + n, err := tlsConn2.Read(buf) + require.NoError(t, err) + assert.Equal(t, "hello-tls", string(buf[:n]), "data should relay to TLS backend") +} + +// TestPortRouter_TLSToHTTPTransition verifies that switching a service from +// TLS-only to HTTP-only via remove+add doesn't orphan the old TLS route. +func TestPortRouter_TLSToHTTPTransition(t *testing.T) { + logger := log.StandardLogger() + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443} + tlsBackend := startEchoTLS(t) + + dialResolve := func(_ types.AccountID) (types.DialContextFunc, error) { + return (&net.Dialer{}).DialContext, nil + } + + router := NewRouter(logger, dialResolve, addr) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { _ = router.Serve(ctx, ln) }() + + // Phase 1: TLS-only. Route relays to backend. + router.AddRoute("app.example.com", Route{ + Type: RouteTCP, + AccountID: "acct-1", + ServiceID: "svc-1", + Target: tlsBackend.Addr().String(), + }) + + tlsConn, err := tls.DialWithDialer( + &net.Dialer{Timeout: 2 * time.Second}, + "tcp", ln.Addr().String(), + &tls.Config{ServerName: "app.example.com", InsecureSkipVerify: true}, + ) + require.NoError(t, err, "TLS relay should work before transition") + _, err = tlsConn.Write([]byte("tls-data")) + require.NoError(t, err) + buf := make([]byte, 1024) + n, err := tlsConn.Read(buf) + require.NoError(t, err) + assert.Equal(t, "tls-data", string(buf[:n])) + tlsConn.Close() + + // Phase 2: Simulate update to HTTP-only (removeMapping + addMapping). + router.RemoveRoute("app.example.com", "svc-1") + router.AddRoute("app.example.com", Route{Type: RouteHTTP, AccountID: "acct-1", ServiceID: "svc-1"}) + + // TLS connection should now go to the HTTP listener, NOT to the old TLS backend. + httpConn := router.HTTPListener() + connDone := make(chan struct{}) + go func() { + defer close(connDone) + c, err := httpConn.Accept() + if err == nil { + c.Close() + } + }() + tlsConn2, err := tls.DialWithDialer( + &net.Dialer{Timeout: 2 * time.Second}, + "tcp", ln.Addr().String(), + &tls.Config{ServerName: "app.example.com", InsecureSkipVerify: true}, + ) + if err == nil { + tlsConn2.Close() + } + select { + case <-connDone: + case <-time.After(2 * time.Second): + t.Fatal("HTTP listener should receive connection after TLS→HTTP transition") + } +} + +// TestPortRouter_MultiDomainSamePort verifies that two TLS services sharing +// the same port router are independently routable and removable. +func TestPortRouter_MultiDomainSamePort(t *testing.T) { + logger := log.StandardLogger() + backend1 := startEchoTLSMulti(t) + backend2 := startEchoTLSMulti(t) + + dialResolve := func(_ types.AccountID) (types.DialContextFunc, error) { + return (&net.Dialer{}).DialContext, nil + } + + router := NewPortRouter(logger, dialResolve) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { _ = router.Serve(ctx, ln) }() + + router.AddRoute("svc1.example.com", Route{Type: RouteTCP, AccountID: "acct-1", ServiceID: "svc-1", Target: backend1.Addr().String()}) + router.AddRoute("svc2.example.com", Route{Type: RouteTCP, AccountID: "acct-1", ServiceID: "svc-2", Target: backend2.Addr().String()}) + assert.False(t, router.IsEmpty()) + + // Both domains route independently. + for _, tc := range []struct { + sni string + data string + }{ + {"svc1.example.com", "hello-svc1"}, + {"svc2.example.com", "hello-svc2"}, + } { + conn, err := tls.DialWithDialer( + &net.Dialer{Timeout: 2 * time.Second}, + "tcp", ln.Addr().String(), + &tls.Config{ServerName: tc.sni, InsecureSkipVerify: true}, + ) + require.NoError(t, err, "dial %s", tc.sni) + _, err = conn.Write([]byte(tc.data)) + require.NoError(t, err) + buf := make([]byte, 1024) + n, err := conn.Read(buf) + require.NoError(t, err) + assert.Equal(t, tc.data, string(buf[:n])) + conn.Close() + } + + // Remove svc1. Router should NOT be empty (svc2 still present). + router.RemoveRoute("svc1.example.com", "svc-1") + assert.False(t, router.IsEmpty(), "router should not be empty with one route remaining") + + // svc2 still works. + conn2, err := tls.DialWithDialer( + &net.Dialer{Timeout: 2 * time.Second}, + "tcp", ln.Addr().String(), + &tls.Config{ServerName: "svc2.example.com", InsecureSkipVerify: true}, + ) + require.NoError(t, err) + _, err = conn2.Write([]byte("still-alive")) + require.NoError(t, err) + buf := make([]byte, 1024) + n, err := conn2.Read(buf) + require.NoError(t, err) + assert.Equal(t, "still-alive", string(buf[:n])) + conn2.Close() + + // Remove svc2. Router is now empty. + router.RemoveRoute("svc2.example.com", "svc-2") + assert.True(t, router.IsEmpty(), "router should be empty after removing all routes") +} + +// TestPortRouter_SNIAndFallbackLifecycle verifies the full lifecycle of SNI +// routes and TCP fallback coexisting on the same port router, including the +// ordering of add/remove operations. +func TestPortRouter_SNIAndFallbackLifecycle(t *testing.T) { + logger := log.StandardLogger() + sniBackend := startEchoTLS(t) + fallbackBackend := startEchoPlain(t) + + dialResolve := func(_ types.AccountID) (types.DialContextFunc, error) { + return (&net.Dialer{}).DialContext, nil + } + + router := NewPortRouter(logger, dialResolve) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { _ = router.Serve(ctx, ln) }() + + // Step 1: Add fallback first (port mapping), then SNI route (TLS service). + router.SetFallback(Route{Type: RouteTCP, AccountID: "acct-1", ServiceID: "pm-1", Target: fallbackBackend.Addr().String()}) + router.AddRoute("tls.example.com", Route{Type: RouteTCP, AccountID: "acct-1", ServiceID: "svc-1", Target: sniBackend.Addr().String()}) + assert.False(t, router.IsEmpty()) + + // SNI traffic goes to TLS backend. + tlsConn, err := tls.DialWithDialer( + &net.Dialer{Timeout: 2 * time.Second}, + "tcp", ln.Addr().String(), + &tls.Config{ServerName: "tls.example.com", InsecureSkipVerify: true}, + ) + require.NoError(t, err) + _, err = tlsConn.Write([]byte("sni-traffic")) + require.NoError(t, err) + buf := make([]byte, 1024) + n, err := tlsConn.Read(buf) + require.NoError(t, err) + assert.Equal(t, "sni-traffic", string(buf[:n])) + tlsConn.Close() + + // Plain TCP goes to fallback. + plainConn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + require.NoError(t, err) + _, err = plainConn.Write([]byte("plain")) + require.NoError(t, err) + n, err = plainConn.Read(buf) + require.NoError(t, err) + assert.Equal(t, "plain", string(buf[:n])) + plainConn.Close() + + // Step 2: Remove SNI route. Fallback still works, router not empty. + router.RemoveRoute("tls.example.com", "svc-1") + assert.False(t, router.IsEmpty(), "fallback still present") + + plainConn2, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + require.NoError(t, err) + // Must send >= 5 bytes so the SNI peek completes immediately + // without waiting for the 5-second peek timeout. + _, err = plainConn2.Write([]byte("after")) + require.NoError(t, err) + n, err = plainConn2.Read(buf) + require.NoError(t, err) + assert.Equal(t, "after", string(buf[:n])) + plainConn2.Close() + + // Step 3: Remove fallback. Router is now empty. + router.RemoveFallback("pm-1") + assert.True(t, router.IsEmpty()) +} + +// TestPortRouter_IsEmptyTransitions verifies IsEmpty reflects correct state +// through all add/remove operations. +func TestPortRouter_IsEmptyTransitions(t *testing.T) { + logger := log.StandardLogger() + router := NewPortRouter(logger, nil) + + assert.True(t, router.IsEmpty(), "new router") + + router.AddRoute("a.com", Route{Type: RouteTCP, ServiceID: "svc-a"}) + assert.False(t, router.IsEmpty(), "after adding route") + + router.SetFallback(Route{Type: RouteTCP, ServiceID: "svc-fb1"}) + assert.False(t, router.IsEmpty(), "route + fallback") + + router.RemoveRoute("a.com", "svc-a") + assert.False(t, router.IsEmpty(), "fallback only") + + router.RemoveFallback("svc-fb1") + assert.True(t, router.IsEmpty(), "all removed") + + // Reverse order: fallback first, then route. + router.SetFallback(Route{Type: RouteTCP, ServiceID: "svc-fb2"}) + assert.False(t, router.IsEmpty()) + + router.AddRoute("b.com", Route{Type: RouteTCP, ServiceID: "svc-b"}) + assert.False(t, router.IsEmpty()) + + router.RemoveFallback("svc-fb2") + assert.False(t, router.IsEmpty(), "route still present") + + router.RemoveRoute("b.com", "svc-b") + assert.True(t, router.IsEmpty(), "fully empty again") +} + +// startEchoTLSMulti starts a TLS echo server that accepts multiple connections. +func startEchoTLSMulti(t *testing.T) net.Listener { + t.Helper() + + cert := generateSelfSignedCert(t) + ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{ + Certificates: []tls.Certificate{cert}, + }) + require.NoError(t, err) + t.Cleanup(func() { ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer c.Close() + buf := make([]byte, 1024) + n, _ := c.Read(buf) + _, _ = c.Write(buf[:n]) + }(conn) + } + }() + + return ln +} + +// startEchoPlain starts a plain TCP echo server that reads until newline +// or connection close, then echoes the received data. +func startEchoPlain(t *testing.T) net.Listener { + t.Helper() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer c.Close() + // Set a read deadline so we don't block forever waiting for more data. + _ = c.SetReadDeadline(time.Now().Add(2 * time.Second)) + buf := make([]byte, 1024) + n, _ := c.Read(buf) + _, _ = c.Write(buf[:n]) + }(conn) + } + }() + + return ln +} + +// fakeAddr implements net.Addr with a custom string representation. +type fakeAddr string + +func (f fakeAddr) Network() string { return "tcp" } +func (f fakeAddr) String() string { return string(f) } + +// fakeConn is a minimal net.Conn with a controllable RemoteAddr. +type fakeConn struct { + net.Conn + remote net.Addr +} + +func (f *fakeConn) RemoteAddr() net.Addr { return f.remote } + +func TestCheckRestrictions_UnparseableAddress(t *testing.T) { + router := NewPortRouter(log.StandardLogger(), nil) + filter := restrict.ParseFilter([]string{"10.0.0.0/8"}, nil, nil, nil) + route := Route{Filter: filter} + + conn := &fakeConn{remote: fakeAddr("not-an-ip")} + assert.NotEqual(t, restrict.Allow, router.checkRestrictions(conn, route), "unparsable address must be denied") +} + +func TestCheckRestrictions_NilRemoteAddr(t *testing.T) { + router := NewPortRouter(log.StandardLogger(), nil) + filter := restrict.ParseFilter([]string{"10.0.0.0/8"}, nil, nil, nil) + route := Route{Filter: filter} + + conn := &fakeConn{remote: nil} + assert.NotEqual(t, restrict.Allow, router.checkRestrictions(conn, route), "nil remote address must be denied") +} + +func TestCheckRestrictions_AllowedAndDenied(t *testing.T) { + router := NewPortRouter(log.StandardLogger(), nil) + filter := restrict.ParseFilter([]string{"10.0.0.0/8"}, nil, nil, nil) + route := Route{Filter: filter} + + allowed := &fakeConn{remote: &net.TCPAddr{IP: net.IPv4(10, 1, 2, 3), Port: 1234}} + assert.Equal(t, restrict.Allow, router.checkRestrictions(allowed, route), "10.1.2.3 in allowlist") + + denied := &fakeConn{remote: &net.TCPAddr{IP: net.IPv4(192, 168, 1, 1), Port: 1234}} + assert.NotEqual(t, restrict.Allow, router.checkRestrictions(denied, route), "192.168.1.1 not in allowlist") +} + +func TestCheckRestrictions_NilFilter(t *testing.T) { + router := NewPortRouter(log.StandardLogger(), nil) + route := Route{Filter: nil} + + conn := &fakeConn{remote: fakeAddr("not-an-ip")} + assert.Equal(t, restrict.Allow, router.checkRestrictions(conn, route), "nil filter should allow everything") +} + +func TestCheckRestrictions_IPv4MappedIPv6(t *testing.T) { + router := NewPortRouter(log.StandardLogger(), nil) + filter := restrict.ParseFilter([]string{"10.0.0.0/8"}, nil, nil, nil) + route := Route{Filter: filter} + + // net.IPv4() returns a 16-byte v4-in-v6 representation internally. + // The restriction check must Unmap it to match the v4 CIDR. + conn := &fakeConn{remote: &net.TCPAddr{IP: net.IPv4(10, 1, 2, 3), Port: 5678}} + assert.Equal(t, restrict.Allow, router.checkRestrictions(conn, route), "v4-in-v6 TCPAddr must match v4 CIDR") + + // Explicitly v4-mapped-v6 address string. + conn6 := &fakeConn{remote: fakeAddr("[::ffff:10.1.2.3]:5678")} + assert.Equal(t, restrict.Allow, router.checkRestrictions(conn6, route), "::ffff:10.1.2.3 must match v4 CIDR") + + connOutside := &fakeConn{remote: fakeAddr("[::ffff:192.168.1.1]:5678")} + assert.NotEqual(t, restrict.Allow, router.checkRestrictions(connOutside, route), "::ffff:192.168.1.1 not in v4 CIDR") +} diff --git a/proxy/internal/tcp/snipeek.go b/proxy/internal/tcp/snipeek.go new file mode 100644 index 000000000..25ab8e5ef --- /dev/null +++ b/proxy/internal/tcp/snipeek.go @@ -0,0 +1,191 @@ +package tcp + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "net" +) + +const ( + // TLS record header is 5 bytes: ContentType(1) + Version(2) + Length(2). + tlsRecordHeaderLen = 5 + // TLS handshake type for ClientHello. + handshakeTypeClientHello = 1 + // TLS ContentType for handshake messages. + contentTypeHandshake = 22 + // SNI extension type (RFC 6066). + extensionServerName = 0 + // SNI host name type. + sniHostNameType = 0 + // maxClientHelloLen caps the ClientHello size we're willing to buffer. + maxClientHelloLen = 16384 + // maxSNILen is the maximum valid DNS hostname length per RFC 1035. + maxSNILen = 253 +) + +// PeekClientHello reads the TLS ClientHello from conn, extracts the SNI +// server name, and returns a wrapped connection that replays the peeked +// bytes transparently. If the data is not a valid TLS ClientHello or +// contains no SNI extension, sni is empty and err is nil. +// +// ECH/ESNI: When the client uses Encrypted Client Hello (TLS 1.3), the +// real server name is encrypted inside the encrypted_client_hello +// extension. This parser only reads the cleartext server_name extension +// (type 0x0000), so ECH connections return sni="" and are routed through +// the fallback path (or HTTP channel), which is the correct behavior +// for a transparent proxy that does not terminate TLS. +func PeekClientHello(conn net.Conn) (sni string, wrapped net.Conn, err error) { + // Read the 5-byte TLS record header into a small stack-friendly buffer. + var header [tlsRecordHeaderLen]byte + if _, err := io.ReadFull(conn, header[:]); err != nil { + return "", nil, fmt.Errorf("read TLS record header: %w", err) + } + + if header[0] != contentTypeHandshake { + return "", newPeekedConn(conn, header[:]), nil + } + + recordLen := int(binary.BigEndian.Uint16(header[3:5])) + if recordLen == 0 || recordLen > maxClientHelloLen { + return "", newPeekedConn(conn, header[:]), nil + } + + // Single allocation for header + payload. The peekedConn takes + // ownership of this buffer, so no further copies are needed. + buf := make([]byte, tlsRecordHeaderLen+recordLen) + copy(buf, header[:]) + + n, err := io.ReadFull(conn, buf[tlsRecordHeaderLen:]) + if err != nil { + return "", newPeekedConn(conn, buf[:tlsRecordHeaderLen+n]), fmt.Errorf("read TLS handshake payload: %w", err) + } + + sni = extractSNI(buf[tlsRecordHeaderLen:]) + return sni, newPeekedConn(conn, buf), nil +} + +// extractSNI parses a TLS handshake payload to find the SNI extension. +// Returns empty string if the payload is not a ClientHello or has no SNI. +func extractSNI(payload []byte) string { + if len(payload) < 4 { + return "" + } + + if payload[0] != handshakeTypeClientHello { + return "" + } + + // Handshake length (3 bytes, big-endian). + handshakeLen := int(payload[1])<<16 | int(payload[2])<<8 | int(payload[3]) + if handshakeLen > len(payload)-4 { + return "" + } + + return parseSNIFromClientHello(payload[4 : 4+handshakeLen]) +} + +// parseSNIFromClientHello walks the ClientHello message fields to reach +// the extensions block and extract the server_name extension value. +func parseSNIFromClientHello(msg []byte) string { + // ClientHello layout: + // ProtocolVersion(2) + Random(32) = 34 bytes minimum before session_id + if len(msg) < 34 { + return "" + } + + pos := 34 + + // Session ID (variable, 1 byte length prefix). + if pos >= len(msg) { + return "" + } + sessionIDLen := int(msg[pos]) + pos++ + pos += sessionIDLen + + // Cipher suites (variable, 2 byte length prefix). + if pos+2 > len(msg) { + return "" + } + cipherSuitesLen := int(binary.BigEndian.Uint16(msg[pos : pos+2])) + pos += 2 + cipherSuitesLen + + // Compression methods (variable, 1 byte length prefix). + if pos >= len(msg) { + return "" + } + compMethodsLen := int(msg[pos]) + pos++ + pos += compMethodsLen + + // Extensions (variable, 2 byte length prefix). + if pos+2 > len(msg) { + return "" + } + extensionsLen := int(binary.BigEndian.Uint16(msg[pos : pos+2])) + pos += 2 + + extensionsEnd := pos + extensionsLen + if extensionsEnd > len(msg) { + return "" + } + + return findSNIExtension(msg[pos:extensionsEnd]) +} + +// findSNIExtension iterates over TLS extensions and returns the host +// name from the server_name extension, if present. +func findSNIExtension(extensions []byte) string { + pos := 0 + for pos+4 <= len(extensions) { + extType := binary.BigEndian.Uint16(extensions[pos : pos+2]) + extLen := int(binary.BigEndian.Uint16(extensions[pos+2 : pos+4])) + pos += 4 + + if pos+extLen > len(extensions) { + return "" + } + + if extType == extensionServerName { + return parseSNIExtensionData(extensions[pos : pos+extLen]) + } + pos += extLen + } + return "" +} + +// parseSNIExtensionData parses the ServerNameList structure inside an +// SNI extension to extract the host name. +func parseSNIExtensionData(data []byte) string { + if len(data) < 2 { + return "" + } + listLen := int(binary.BigEndian.Uint16(data[0:2])) + if listLen > len(data)-2 { + return "" + } + + list := data[2 : 2+listLen] + pos := 0 + for pos+3 <= len(list) { + nameType := list[pos] + nameLen := int(binary.BigEndian.Uint16(list[pos+1 : pos+3])) + pos += 3 + + if pos+nameLen > len(list) { + return "" + } + + if nameType == sniHostNameType { + name := list[pos : pos+nameLen] + if nameLen > maxSNILen || bytes.ContainsRune(name, 0) { + return "" + } + return string(name) + } + pos += nameLen + } + return "" +} diff --git a/proxy/internal/tcp/snipeek_test.go b/proxy/internal/tcp/snipeek_test.go new file mode 100644 index 000000000..9afe6261d --- /dev/null +++ b/proxy/internal/tcp/snipeek_test.go @@ -0,0 +1,251 @@ +package tcp + +import ( + "crypto/tls" + "io" + "net" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPeekClientHello_ValidSNI(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + + const expectedSNI = "example.com" + trailingData := []byte("trailing data after handshake") + + go func() { + tlsConn := tls.Client(clientConn, &tls.Config{ + ServerName: expectedSNI, + InsecureSkipVerify: true, //nolint:gosec + }) + // The Handshake will send the ClientHello. It will fail because + // our server side isn't doing a real TLS handshake, but that's + // fine: we only need the ClientHello to be sent. + _ = tlsConn.Handshake() + }() + + sni, wrapped, err := PeekClientHello(serverConn) + require.NoError(t, err) + assert.Equal(t, expectedSNI, sni, "should extract SNI from ClientHello") + assert.NotNil(t, wrapped, "wrapped connection should not be nil") + + // Verify the wrapped connection replays the peeked bytes. + // Read the first 5 bytes (TLS record header) to confirm replay. + buf := make([]byte, 5) + n, err := wrapped.Read(buf) + require.NoError(t, err) + assert.Equal(t, 5, n) + assert.Equal(t, byte(contentTypeHandshake), buf[0], "first byte should be TLS handshake content type") + + // Write trailing data from the client side and verify it arrives + // through the wrapped connection after the peeked bytes. + go func() { + _, _ = clientConn.Write(trailingData) + }() + + // Drain the rest of the peeked ClientHello first. + peekedRest := make([]byte, 16384) + _, _ = wrapped.Read(peekedRest) + + got := make([]byte, len(trailingData)) + n, err = io.ReadFull(wrapped, got) + require.NoError(t, err) + assert.Equal(t, trailingData, got[:n]) +} + +func TestPeekClientHello_MultipleSNIs(t *testing.T) { + tests := []struct { + name string + serverName string + expectedSNI string + }{ + {"simple domain", "example.com", "example.com"}, + {"subdomain", "sub.example.com", "sub.example.com"}, + {"deep subdomain", "a.b.c.example.com", "a.b.c.example.com"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + + go func() { + tlsConn := tls.Client(clientConn, &tls.Config{ + ServerName: tt.serverName, + InsecureSkipVerify: true, //nolint:gosec + }) + _ = tlsConn.Handshake() + }() + + sni, wrapped, err := PeekClientHello(serverConn) + require.NoError(t, err) + assert.Equal(t, tt.expectedSNI, sni) + assert.NotNil(t, wrapped) + }) + } +} + +func TestPeekClientHello_NonTLSData(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + + // Send plain HTTP data (not TLS). + httpData := []byte("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n") + go func() { + _, _ = clientConn.Write(httpData) + }() + + sni, wrapped, err := PeekClientHello(serverConn) + require.NoError(t, err) + assert.Empty(t, sni, "should return empty SNI for non-TLS data") + assert.NotNil(t, wrapped) + + // Verify the wrapped connection still provides the original data. + buf := make([]byte, len(httpData)) + n, err := io.ReadFull(wrapped, buf) + require.NoError(t, err) + assert.Equal(t, httpData, buf[:n], "wrapped connection should replay original data") +} + +func TestPeekClientHello_TruncatedHeader(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer serverConn.Close() + + // Write only 3 bytes then close, fewer than the 5-byte TLS header. + go func() { + _, _ = clientConn.Write([]byte{0x16, 0x03, 0x01}) + clientConn.Close() + }() + + _, _, err := PeekClientHello(serverConn) + assert.Error(t, err, "should error on truncated header") +} + +func TestPeekClientHello_TruncatedPayload(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer serverConn.Close() + + // Write a valid TLS header claiming 100 bytes, but only send 10. + go func() { + header := []byte{0x16, 0x03, 0x01, 0x00, 0x64} // 100 bytes claimed + _, _ = clientConn.Write(header) + _, _ = clientConn.Write(make([]byte, 10)) + clientConn.Close() + }() + + _, _, err := PeekClientHello(serverConn) + assert.Error(t, err, "should error on truncated payload") +} + +func TestPeekClientHello_ZeroLengthRecord(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + + // TLS handshake header with zero-length payload. + go func() { + _, _ = clientConn.Write([]byte{0x16, 0x03, 0x01, 0x00, 0x00}) + }() + + sni, wrapped, err := PeekClientHello(serverConn) + require.NoError(t, err) + assert.Empty(t, sni) + assert.NotNil(t, wrapped) +} + +func TestExtractSNI_InvalidPayload(t *testing.T) { + tests := []struct { + name string + payload []byte + }{ + {"nil", nil}, + {"empty", []byte{}}, + {"too short", []byte{0x01, 0x00}}, + {"wrong handshake type", []byte{0x02, 0x00, 0x00, 0x05, 0x03, 0x03, 0x00, 0x00, 0x00}}, + {"truncated client hello", []byte{0x01, 0x00, 0x00, 0x20}}, // claims 32 bytes but has none + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Empty(t, extractSNI(tt.payload)) + }) + } +} + +func TestPeekedConn_CloseWrite(t *testing.T) { + t.Run("delegates to underlying TCPConn", func(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + accepted := make(chan net.Conn, 1) + go func() { + c, err := ln.Accept() + if err == nil { + accepted <- c + } + }() + + client, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + defer client.Close() + + server := <-accepted + defer server.Close() + + wrapped := newPeekedConn(server, []byte("peeked")) + + // CloseWrite should succeed on a real TCP connection. + err = wrapped.CloseWrite() + assert.NoError(t, err) + + // The client should see EOF on reads after CloseWrite. + buf := make([]byte, 1) + _, err = client.Read(buf) + assert.Equal(t, io.EOF, err, "client should see EOF after half-close") + }) + + t.Run("no-op on non-halfcloser", func(t *testing.T) { + // net.Pipe does not implement CloseWrite. + _, server := net.Pipe() + defer server.Close() + + wrapped := newPeekedConn(server, []byte("peeked")) + err := wrapped.CloseWrite() + assert.NoError(t, err, "should be no-op on non-halfcloser") + }) +} + +func TestPeekedConn_ReplayAndPassthrough(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + + peeked := []byte("peeked-data") + subsequent := []byte("subsequent-data") + + wrapped := newPeekedConn(serverConn, peeked) + + go func() { + _, _ = clientConn.Write(subsequent) + }() + + // Read should return peeked data first. + buf := make([]byte, len(peeked)) + n, err := io.ReadFull(wrapped, buf) + require.NoError(t, err) + assert.Equal(t, peeked, buf[:n]) + + // Then subsequent data from the real connection. + buf = make([]byte, len(subsequent)) + n, err = io.ReadFull(wrapped, buf) + require.NoError(t, err) + assert.Equal(t, subsequent, buf[:n]) +} diff --git a/proxy/internal/types/types.go b/proxy/internal/types/types.go new file mode 100644 index 000000000..bf3731803 --- /dev/null +++ b/proxy/internal/types/types.go @@ -0,0 +1,56 @@ +// Package types defines common types used across the proxy package. +package types + +import ( + "context" + "net" + "time" +) + +// AccountID represents a unique identifier for a NetBird account. +type AccountID string + +// ServiceID represents a unique identifier for a proxy service. +type ServiceID string + +// ServiceMode describes how a reverse proxy service is exposed. +type ServiceMode string + +const ( + ServiceModeHTTP ServiceMode = "http" + ServiceModeTCP ServiceMode = "tcp" + ServiceModeUDP ServiceMode = "udp" + ServiceModeTLS ServiceMode = "tls" +) + +// IsL4 returns true for TCP, UDP, and TLS modes. +func (m ServiceMode) IsL4() bool { + return m == ServiceModeTCP || m == ServiceModeUDP || m == ServiceModeTLS +} + +// RelayDirection indicates the direction of a relayed packet. +type RelayDirection string + +const ( + RelayDirectionClientToBackend RelayDirection = "client_to_backend" + RelayDirectionBackendToClient RelayDirection = "backend_to_client" +) + +// DialContextFunc dials a backend through the WireGuard tunnel. +type DialContextFunc func(ctx context.Context, network, address string) (net.Conn, error) + +// dialTimeoutKey is the context key for a per-request dial timeout. +type dialTimeoutKey struct{} + +// WithDialTimeout returns a context carrying a dial timeout that +// DialContext wrappers can use to scope the timeout to just the +// connection establishment phase. +func WithDialTimeout(ctx context.Context, d time.Duration) context.Context { + return context.WithValue(ctx, dialTimeoutKey{}, d) +} + +// DialTimeoutFromContext returns the dial timeout from the context, if set. +func DialTimeoutFromContext(ctx context.Context) (time.Duration, bool) { + d, ok := ctx.Value(dialTimeoutKey{}).(time.Duration) + return d, ok && d > 0 +} diff --git a/proxy/internal/types/types_test.go b/proxy/internal/types/types_test.go new file mode 100644 index 000000000..dd9738442 --- /dev/null +++ b/proxy/internal/types/types_test.go @@ -0,0 +1,54 @@ +package types + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestServiceMode_IsL4(t *testing.T) { + tests := []struct { + mode ServiceMode + want bool + }{ + {ServiceModeHTTP, false}, + {ServiceModeTCP, true}, + {ServiceModeUDP, true}, + {ServiceModeTLS, true}, + {ServiceMode("unknown"), false}, + } + + for _, tt := range tests { + t.Run(string(tt.mode), func(t *testing.T) { + assert.Equal(t, tt.want, tt.mode.IsL4()) + }) + } +} + +func TestDialTimeoutContext(t *testing.T) { + t.Run("round trip", func(t *testing.T) { + ctx := WithDialTimeout(context.Background(), 5*time.Second) + d, ok := DialTimeoutFromContext(ctx) + assert.True(t, ok) + assert.Equal(t, 5*time.Second, d) + }) + + t.Run("missing", func(t *testing.T) { + _, ok := DialTimeoutFromContext(context.Background()) + assert.False(t, ok) + }) + + t.Run("zero returns false", func(t *testing.T) { + ctx := WithDialTimeout(context.Background(), 0) + _, ok := DialTimeoutFromContext(ctx) + assert.False(t, ok, "zero duration should return ok=false") + }) + + t.Run("negative returns false", func(t *testing.T) { + ctx := WithDialTimeout(context.Background(), -1*time.Second) + _, ok := DialTimeoutFromContext(ctx) + assert.False(t, ok, "negative duration should return ok=false") + }) +} diff --git a/proxy/internal/udp/relay.go b/proxy/internal/udp/relay.go new file mode 100644 index 000000000..d20ecf48b --- /dev/null +++ b/proxy/internal/udp/relay.go @@ -0,0 +1,560 @@ +package udp + +import ( + "context" + "errors" + "fmt" + "net" + "net/netip" + "sync" + "sync/atomic" + "time" + + log "github.com/sirupsen/logrus" + "golang.org/x/time/rate" + + "github.com/netbirdio/netbird/proxy/internal/accesslog" + "github.com/netbirdio/netbird/proxy/internal/netutil" + "github.com/netbirdio/netbird/proxy/internal/restrict" + "github.com/netbirdio/netbird/proxy/internal/types" +) + +const ( + // DefaultSessionTTL is the default idle timeout for UDP sessions before cleanup. + DefaultSessionTTL = 30 * time.Second + // cleanupInterval is how often the cleaner goroutine runs. + cleanupInterval = time.Minute + // maxPacketSize is the maximum UDP packet size we'll handle. + maxPacketSize = 65535 + // DefaultMaxSessions is the default cap on concurrent UDP sessions per relay. + DefaultMaxSessions = 1024 + // sessionCreateRate limits new session creation per second. + sessionCreateRate = 50 + // sessionCreateBurst is the burst allowance for session creation. + sessionCreateBurst = 100 + // defaultDialTimeout is the fallback dial timeout for backend connections. + defaultDialTimeout = 30 * time.Second +) + +// l4Logger sends layer-4 access log entries to the management server. +type l4Logger interface { + LogL4(entry accesslog.L4Entry) +} + +// SessionObserver receives callbacks for UDP session lifecycle events. +// All methods must be safe for concurrent use. +type SessionObserver interface { + UDPSessionStarted(accountID types.AccountID) + UDPSessionEnded(accountID types.AccountID) + UDPSessionDialError(accountID types.AccountID) + UDPSessionRejected(accountID types.AccountID) + UDPPacketRelayed(direction types.RelayDirection, bytes int) +} + +// clientAddr is a typed key for UDP session lookups. +type clientAddr string + +// Relay listens for incoming UDP packets on a dedicated port and +// maintains per-client sessions that relay packets to a backend +// through the WireGuard tunnel. +type Relay struct { + logger *log.Entry + listener net.PacketConn + target string + domain string + accountID types.AccountID + serviceID types.ServiceID + dialFunc types.DialContextFunc + dialTimeout time.Duration + sessionTTL time.Duration + maxSessions int + filter *restrict.Filter + geo restrict.GeoResolver + + mu sync.RWMutex + sessions map[clientAddr]*session + + bufPool sync.Pool + sessLimiter *rate.Limiter + sessWg sync.WaitGroup + ctx context.Context + cancel context.CancelFunc + observer SessionObserver + accessLog l4Logger +} + +type session struct { + backend net.Conn + addr net.Addr + createdAt time.Time + // lastSeen stores the last activity timestamp as unix nanoseconds. + lastSeen atomic.Int64 + cancel context.CancelFunc + // bytesIn tracks total bytes received from the client. + bytesIn atomic.Int64 + // bytesOut tracks total bytes sent back to the client. + bytesOut atomic.Int64 +} + +func (s *session) updateLastSeen() { + s.lastSeen.Store(time.Now().UnixNano()) +} + +func (s *session) idleDuration() time.Duration { + return time.Since(time.Unix(0, s.lastSeen.Load())) +} + +// RelayConfig holds the configuration for a UDP relay. +type RelayConfig struct { + Logger *log.Entry + Listener net.PacketConn + Target string + Domain string + AccountID types.AccountID + ServiceID types.ServiceID + DialFunc types.DialContextFunc + DialTimeout time.Duration + SessionTTL time.Duration + MaxSessions int + AccessLog l4Logger + // Filter holds connection-level IP/geo restrictions. Nil means no restrictions. + Filter *restrict.Filter + // Geo is the geolocation lookup used for country-based restrictions. + Geo restrict.GeoResolver +} + +// New creates a UDP relay for the given listener and backend target. +// MaxSessions caps the number of concurrent sessions; use 0 for DefaultMaxSessions. +// DialTimeout controls how long to wait for backend connections; use 0 for default. +// SessionTTL is the idle timeout before a session is reaped; use 0 for DefaultSessionTTL. +func New(parentCtx context.Context, cfg RelayConfig) *Relay { + maxSessions := cfg.MaxSessions + dialTimeout := cfg.DialTimeout + sessionTTL := cfg.SessionTTL + if maxSessions <= 0 { + maxSessions = DefaultMaxSessions + } + if dialTimeout <= 0 { + dialTimeout = defaultDialTimeout + } + if sessionTTL <= 0 { + sessionTTL = DefaultSessionTTL + } + ctx, cancel := context.WithCancel(parentCtx) + return &Relay{ + logger: cfg.Logger, + listener: cfg.Listener, + target: cfg.Target, + domain: cfg.Domain, + accountID: cfg.AccountID, + serviceID: cfg.ServiceID, + accessLog: cfg.AccessLog, + dialFunc: cfg.DialFunc, + dialTimeout: dialTimeout, + sessionTTL: sessionTTL, + maxSessions: maxSessions, + filter: cfg.Filter, + geo: cfg.Geo, + sessions: make(map[clientAddr]*session), + bufPool: sync.Pool{ + New: func() any { + buf := make([]byte, maxPacketSize) + return &buf + }, + }, + sessLimiter: rate.NewLimiter(sessionCreateRate, sessionCreateBurst), + ctx: ctx, + cancel: cancel, + } +} + +// ServiceID returns the service ID associated with this relay. +func (r *Relay) ServiceID() types.ServiceID { + return r.serviceID +} + +// SetObserver sets the session lifecycle observer. Must be called before Serve. +func (r *Relay) SetObserver(obs SessionObserver) { + r.mu.Lock() + defer r.mu.Unlock() + r.observer = obs +} + +// getObserver returns the current session lifecycle observer. +func (r *Relay) getObserver() SessionObserver { + r.mu.RLock() + defer r.mu.RUnlock() + return r.observer +} + +// Serve starts the relay loop. It blocks until the context is canceled +// or the listener is closed. +func (r *Relay) Serve() { + go r.cleanupLoop() + + for { + bufp := r.bufPool.Get().(*[]byte) + buf := *bufp + + n, addr, err := r.listener.ReadFrom(buf) + if err != nil { + r.bufPool.Put(bufp) + if r.ctx.Err() != nil || errors.Is(err, net.ErrClosed) { + return + } + r.logger.Debugf("UDP read: %v", err) + continue + } + + data := buf[:n] + sess, err := r.getOrCreateSession(addr) + if err != nil { + r.bufPool.Put(bufp) + r.logger.Debugf("create UDP session for %s: %v", addr, err) + continue + } + + sess.updateLastSeen() + + nw, err := sess.backend.Write(data) + if err != nil { + r.bufPool.Put(bufp) + if !netutil.IsExpectedError(err) { + r.logger.Debugf("UDP write to backend for %s: %v", addr, err) + } + r.removeSession(sess) + continue + } + sess.bytesIn.Add(int64(nw)) + + if obs := r.getObserver(); obs != nil { + obs.UDPPacketRelayed(types.RelayDirectionClientToBackend, nw) + } + r.bufPool.Put(bufp) + } +} + +// getOrCreateSession returns an existing session or creates a new one. +func (r *Relay) getOrCreateSession(addr net.Addr) (*session, error) { + key := clientAddr(addr.String()) + + r.mu.RLock() + sess, ok := r.sessions[key] + r.mu.RUnlock() + if ok && sess != nil { + return sess, nil + } + + // Check before taking the write lock: if the relay is shutting down, + // don't create new sessions. This prevents orphaned goroutines when + // Serve() processes a packet that was already read before Close(). + if r.ctx.Err() != nil { + return nil, r.ctx.Err() + } + + if err := r.checkAccessRestrictions(addr); err != nil { + return nil, err + } + + r.mu.Lock() + + if sess, ok = r.sessions[key]; ok && sess != nil { + r.mu.Unlock() + return sess, nil + } + if ok { + // Another goroutine is dialing for this key, skip. + r.mu.Unlock() + return nil, fmt.Errorf("session dial in progress for %s", key) + } + + if len(r.sessions) >= r.maxSessions { + r.mu.Unlock() + if obs := r.getObserver(); obs != nil { + obs.UDPSessionRejected(r.accountID) + } + return nil, fmt.Errorf("session limit reached (%d)", r.maxSessions) + } + + if !r.sessLimiter.Allow() { + r.mu.Unlock() + if obs := r.getObserver(); obs != nil { + obs.UDPSessionRejected(r.accountID) + } + return nil, fmt.Errorf("session creation rate limited") + } + + // Reserve the slot with a nil session so concurrent callers for the same + // key see it exists and wait. Release the lock before dialing. + r.sessions[key] = nil + r.mu.Unlock() + + dialCtx, dialCancel := context.WithTimeout(r.ctx, r.dialTimeout) + backend, err := r.dialFunc(dialCtx, "udp", r.target) + dialCancel() + if err != nil { + r.mu.Lock() + delete(r.sessions, key) + r.mu.Unlock() + if obs := r.getObserver(); obs != nil { + obs.UDPSessionDialError(r.accountID) + } + return nil, fmt.Errorf("dial backend %s: %w", r.target, err) + } + + sessCtx, sessCancel := context.WithCancel(r.ctx) + sess = &session{ + backend: backend, + addr: addr, + createdAt: time.Now(), + cancel: sessCancel, + } + sess.updateLastSeen() + + r.mu.Lock() + r.sessions[key] = sess + r.mu.Unlock() + + if obs := r.getObserver(); obs != nil { + obs.UDPSessionStarted(r.accountID) + } + + r.sessWg.Go(func() { + r.relayBackendToClient(sessCtx, sess) + }) + + r.logger.Debugf("UDP session created for %s", addr) + return sess, nil +} + +func (r *Relay) checkAccessRestrictions(addr net.Addr) error { + if r.filter == nil { + return nil + } + clientIP, err := addrFromUDPAddr(addr) + if err != nil { + return fmt.Errorf("parse client address %s for restriction check: %w", addr, err) + } + if v := r.filter.Check(clientIP, r.geo); v != restrict.Allow { + r.logDeny(clientIP, v) + return fmt.Errorf("access restricted for %s", addr) + } + return nil +} + +// relayBackendToClient reads packets from the backend and writes them +// back to the client through the public-facing listener. +func (r *Relay) relayBackendToClient(ctx context.Context, sess *session) { + bufp := r.bufPool.Get().(*[]byte) + defer r.bufPool.Put(bufp) + defer r.removeSession(sess) + + for ctx.Err() == nil { + data, ok := r.readBackendPacket(sess, *bufp) + if !ok { + return + } + if data == nil { + continue + } + + sess.updateLastSeen() + + nw, err := r.listener.WriteTo(data, sess.addr) + if err != nil { + if !netutil.IsExpectedError(err) { + r.logger.Debugf("UDP write to client %s: %v", sess.addr, err) + } + return + } + sess.bytesOut.Add(int64(nw)) + + if obs := r.getObserver(); obs != nil { + obs.UDPPacketRelayed(types.RelayDirectionBackendToClient, nw) + } + } +} + +// readBackendPacket reads one packet from the backend with an idle deadline. +// Returns (data, true) on success, (nil, true) on idle timeout that should +// retry, or (nil, false) when the session should be torn down. +func (r *Relay) readBackendPacket(sess *session, buf []byte) ([]byte, bool) { + if err := sess.backend.SetReadDeadline(time.Now().Add(r.sessionTTL)); err != nil { + r.logger.Debugf("set backend read deadline for %s: %v", sess.addr, err) + return nil, false + } + + n, err := sess.backend.Read(buf) + if err != nil { + if netutil.IsTimeout(err) { + if sess.idleDuration() > r.sessionTTL { + return nil, false + } + return nil, true + } + if !netutil.IsExpectedError(err) { + r.logger.Debugf("UDP read from backend for %s: %v", sess.addr, err) + } + return nil, false + } + + return buf[:n], true +} + +// cleanupLoop periodically removes idle sessions. +func (r *Relay) cleanupLoop() { + ticker := time.NewTicker(cleanupInterval) + defer ticker.Stop() + + for { + select { + case <-r.ctx.Done(): + return + case <-ticker.C: + r.cleanupIdleSessions() + } + } +} + +// cleanupIdleSessions closes sessions that have been idle for too long. +func (r *Relay) cleanupIdleSessions() { + var expired []*session + + r.mu.Lock() + for key, sess := range r.sessions { + if sess == nil { + continue + } + idle := sess.idleDuration() + if idle > r.sessionTTL { + r.logger.Debugf("UDP session %s idle for %s, closing (client→backend: %d bytes, backend→client: %d bytes)", + sess.addr, idle, sess.bytesIn.Load(), sess.bytesOut.Load()) + delete(r.sessions, key) + sess.cancel() + if err := sess.backend.Close(); err != nil { + r.logger.Debugf("close idle session %s backend: %v", sess.addr, err) + } + expired = append(expired, sess) + } + } + r.mu.Unlock() + + obs := r.getObserver() + for _, sess := range expired { + if obs != nil { + obs.UDPSessionEnded(r.accountID) + } + r.logSessionEnd(sess) + } +} + +// removeSession removes a session from the map if it still matches the +// given pointer. This is safe to call concurrently with cleanupIdleSessions +// because the identity check prevents double-close when both paths race. +func (r *Relay) removeSession(sess *session) { + r.mu.Lock() + key := clientAddr(sess.addr.String()) + removed := r.sessions[key] == sess + if removed { + delete(r.sessions, key) + sess.cancel() + if err := sess.backend.Close(); err != nil { + r.logger.Debugf("close session %s backend: %v", sess.addr, err) + } + } + r.mu.Unlock() + + if removed { + r.logger.Debugf("UDP session %s ended (client→backend: %d bytes, backend→client: %d bytes)", + sess.addr, sess.bytesIn.Load(), sess.bytesOut.Load()) + if obs := r.getObserver(); obs != nil { + obs.UDPSessionEnded(r.accountID) + } + r.logSessionEnd(sess) + } +} + +// logSessionEnd sends an access log entry for a completed UDP session. +func (r *Relay) logSessionEnd(sess *session) { + if r.accessLog == nil { + return + } + + var sourceIP netip.Addr + if ap, err := netip.ParseAddrPort(sess.addr.String()); err == nil { + sourceIP = ap.Addr().Unmap() + } + + r.accessLog.LogL4(accesslog.L4Entry{ + AccountID: r.accountID, + ServiceID: r.serviceID, + Protocol: accesslog.ProtocolUDP, + Host: r.domain, + SourceIP: sourceIP, + DurationMs: time.Unix(0, sess.lastSeen.Load()).Sub(sess.createdAt).Milliseconds(), + BytesUpload: sess.bytesIn.Load(), + BytesDownload: sess.bytesOut.Load(), + }) +} + +// logDeny sends an access log entry for a denied UDP packet. +func (r *Relay) logDeny(clientIP netip.Addr, verdict restrict.Verdict) { + if r.accessLog == nil { + return + } + + r.accessLog.LogL4(accesslog.L4Entry{ + AccountID: r.accountID, + ServiceID: r.serviceID, + Protocol: accesslog.ProtocolUDP, + Host: r.domain, + SourceIP: clientIP, + DenyReason: verdict.String(), + }) +} + +// Close stops the relay, waits for all session goroutines to exit, +// and cleans up remaining sessions. +func (r *Relay) Close() { + r.cancel() + if err := r.listener.Close(); err != nil { + r.logger.Debugf("close UDP listener: %v", err) + } + + var closedSessions []*session + r.mu.Lock() + for key, sess := range r.sessions { + if sess == nil { + delete(r.sessions, key) + continue + } + r.logger.Debugf("UDP session %s closed (client→backend: %d bytes, backend→client: %d bytes)", + sess.addr, sess.bytesIn.Load(), sess.bytesOut.Load()) + sess.cancel() + if err := sess.backend.Close(); err != nil { + r.logger.Debugf("close session %s backend: %v", sess.addr, err) + } + delete(r.sessions, key) + closedSessions = append(closedSessions, sess) + } + r.mu.Unlock() + + obs := r.getObserver() + for _, sess := range closedSessions { + if obs != nil { + obs.UDPSessionEnded(r.accountID) + } + r.logSessionEnd(sess) + } + + r.sessWg.Wait() +} + +// addrFromUDPAddr extracts a netip.Addr from a net.Addr. +func addrFromUDPAddr(addr net.Addr) (netip.Addr, error) { + ap, err := netip.ParseAddrPort(addr.String()) + if err != nil { + return netip.Addr{}, err + } + return ap.Addr().Unmap(), nil +} diff --git a/proxy/internal/udp/relay_test.go b/proxy/internal/udp/relay_test.go new file mode 100644 index 000000000..a1e91b290 --- /dev/null +++ b/proxy/internal/udp/relay_test.go @@ -0,0 +1,493 @@ +package udp + +import ( + "context" + "fmt" + "net" + "sync" + "testing" + "time" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/types" +) + +func TestRelay_BasicPacketExchange(t *testing.T) { + // Set up a UDP backend that echoes packets. + backend, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer backend.Close() + + go func() { + buf := make([]byte, 65535) + for { + n, addr, err := backend.ReadFrom(buf) + if err != nil { + return + } + _, _ = backend.WriteTo(buf[:n], addr) + } + }() + + // Set up the relay's public-facing listener. + listener, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer listener.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + logger := log.NewEntry(log.StandardLogger()) + backendAddr := backend.LocalAddr().String() + + dialFunc := func(ctx context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + } + + relay := New(ctx, RelayConfig{Logger: logger, Listener: listener, Target: backendAddr, DialFunc: dialFunc}) + go relay.Serve() + defer relay.Close() + + // Create a client and send a packet to the relay. + client, err := net.Dial("udp", listener.LocalAddr().String()) + require.NoError(t, err) + defer client.Close() + + testData := []byte("hello UDP relay") + _, err = client.Write(testData) + require.NoError(t, err) + + // Read the echoed response. + if err := client.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { + t.Fatal(err) + } + buf := make([]byte, 1024) + n, err := client.Read(buf) + require.NoError(t, err) + assert.Equal(t, testData, buf[:n], "should receive echoed packet") +} + +func TestRelay_MultipleClients(t *testing.T) { + backend, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer backend.Close() + + go func() { + buf := make([]byte, 65535) + for { + n, addr, err := backend.ReadFrom(buf) + if err != nil { + return + } + _, _ = backend.WriteTo(buf[:n], addr) + } + }() + + listener, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer listener.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + logger := log.NewEntry(log.StandardLogger()) + dialFunc := func(ctx context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + } + + relay := New(ctx, RelayConfig{Logger: logger, Listener: listener, Target: backend.LocalAddr().String(), DialFunc: dialFunc}) + go relay.Serve() + defer relay.Close() + + // Two clients, each should get their own session. + for i, msg := range []string{"client-1", "client-2"} { + client, err := net.Dial("udp", listener.LocalAddr().String()) + require.NoError(t, err, "client %d", i) + defer client.Close() + + _, err = client.Write([]byte(msg)) + require.NoError(t, err) + + if err := client.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { + t.Fatal(err) + } + buf := make([]byte, 1024) + n, err := client.Read(buf) + require.NoError(t, err, "client %d read", i) + assert.Equal(t, msg, string(buf[:n]), "client %d should get own echo", i) + } + + // Verify two sessions were created. + relay.mu.RLock() + sessionCount := len(relay.sessions) + relay.mu.RUnlock() + assert.Equal(t, 2, sessionCount, "should have two sessions") +} + +func TestRelay_Close(t *testing.T) { + listener, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + logger := log.NewEntry(log.StandardLogger()) + dialFunc := func(ctx context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + } + + relay := New(ctx, RelayConfig{Logger: logger, Listener: listener, Target: "127.0.0.1:9999", DialFunc: dialFunc}) + + done := make(chan struct{}) + go func() { + relay.Serve() + close(done) + }() + + relay.Close() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("Serve did not return after Close") + } +} + +func TestRelay_SessionCleanup(t *testing.T) { + backend, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer backend.Close() + + go func() { + buf := make([]byte, 65535) + for { + n, addr, err := backend.ReadFrom(buf) + if err != nil { + return + } + _, _ = backend.WriteTo(buf[:n], addr) + } + }() + + listener, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer listener.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + logger := log.NewEntry(log.StandardLogger()) + dialFunc := func(ctx context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + } + + relay := New(ctx, RelayConfig{Logger: logger, Listener: listener, Target: backend.LocalAddr().String(), DialFunc: dialFunc}) + go relay.Serve() + defer relay.Close() + + // Create a session. + client, err := net.Dial("udp", listener.LocalAddr().String()) + require.NoError(t, err) + _, err = client.Write([]byte("hello")) + require.NoError(t, err) + + if err := client.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { + t.Fatal(err) + } + buf := make([]byte, 1024) + _, err = client.Read(buf) + require.NoError(t, err) + client.Close() + + // Verify session exists. + relay.mu.RLock() + assert.Equal(t, 1, len(relay.sessions)) + relay.mu.RUnlock() + + // Make session appear idle by setting lastSeen to the past. + relay.mu.Lock() + for _, sess := range relay.sessions { + sess.lastSeen.Store(time.Now().Add(-2 * DefaultSessionTTL).UnixNano()) + } + relay.mu.Unlock() + + // Trigger cleanup manually. + relay.cleanupIdleSessions() + + relay.mu.RLock() + assert.Equal(t, 0, len(relay.sessions), "idle sessions should be cleaned up") + relay.mu.RUnlock() +} + +// TestRelay_CloseAndRecreate verifies that closing a relay and creating a new +// one on the same port works cleanly (simulates port mapping modify cycle). +func TestRelay_CloseAndRecreate(t *testing.T) { + backend, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer backend.Close() + + go func() { + buf := make([]byte, 65535) + for { + n, addr, err := backend.ReadFrom(buf) + if err != nil { + return + } + _, _ = backend.WriteTo(buf[:n], addr) + } + }() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + logger := log.NewEntry(log.StandardLogger()) + dialFunc := func(ctx context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + } + + // First relay. + ln1, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + + relay1 := New(ctx, RelayConfig{Logger: logger, Listener: ln1, Target: backend.LocalAddr().String(), DialFunc: dialFunc}) + go relay1.Serve() + + client1, err := net.Dial("udp", ln1.LocalAddr().String()) + require.NoError(t, err) + _, err = client1.Write([]byte("relay1")) + require.NoError(t, err) + require.NoError(t, client1.SetReadDeadline(time.Now().Add(2*time.Second))) + buf := make([]byte, 1024) + n, err := client1.Read(buf) + require.NoError(t, err) + assert.Equal(t, "relay1", string(buf[:n])) + client1.Close() + + // Close first relay. + relay1.Close() + + // Second relay on same port. + port := ln1.LocalAddr().(*net.UDPAddr).Port + ln2, err := net.ListenPacket("udp", fmt.Sprintf("127.0.0.1:%d", port)) + require.NoError(t, err) + + relay2 := New(ctx, RelayConfig{Logger: logger, Listener: ln2, Target: backend.LocalAddr().String(), DialFunc: dialFunc}) + go relay2.Serve() + defer relay2.Close() + + client2, err := net.Dial("udp", ln2.LocalAddr().String()) + require.NoError(t, err) + defer client2.Close() + _, err = client2.Write([]byte("relay2")) + require.NoError(t, err) + require.NoError(t, client2.SetReadDeadline(time.Now().Add(2*time.Second))) + n, err = client2.Read(buf) + require.NoError(t, err) + assert.Equal(t, "relay2", string(buf[:n]), "second relay should work on same port") +} + +func TestRelay_SessionLimit(t *testing.T) { + backend, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer backend.Close() + + go func() { + buf := make([]byte, 65535) + for { + n, addr, err := backend.ReadFrom(buf) + if err != nil { + return + } + _, _ = backend.WriteTo(buf[:n], addr) + } + }() + + listener, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer listener.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + logger := log.NewEntry(log.StandardLogger()) + dialFunc := func(ctx context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + } + + // Create a relay with a max of 2 sessions. + relay := New(ctx, RelayConfig{Logger: logger, Listener: listener, Target: backend.LocalAddr().String(), DialFunc: dialFunc, MaxSessions: 2}) + go relay.Serve() + defer relay.Close() + + // Create 2 clients to fill up the session limit. + for i := range 2 { + client, err := net.Dial("udp", listener.LocalAddr().String()) + require.NoError(t, err, "client %d", i) + defer client.Close() + + _, err = client.Write([]byte("hello")) + require.NoError(t, err) + + require.NoError(t, client.SetReadDeadline(time.Now().Add(2*time.Second))) + buf := make([]byte, 1024) + _, err = client.Read(buf) + require.NoError(t, err, "client %d should get response", i) + } + + relay.mu.RLock() + assert.Equal(t, 2, len(relay.sessions), "should have exactly 2 sessions") + relay.mu.RUnlock() + + // Third client should get its packet dropped (session creation fails). + client3, err := net.Dial("udp", listener.LocalAddr().String()) + require.NoError(t, err) + defer client3.Close() + + _, err = client3.Write([]byte("should be dropped")) + require.NoError(t, err) + + require.NoError(t, client3.SetReadDeadline(time.Now().Add(500*time.Millisecond))) + buf := make([]byte, 1024) + _, err = client3.Read(buf) + assert.Error(t, err, "third client should time out because session was rejected") + + relay.mu.RLock() + assert.Equal(t, 2, len(relay.sessions), "session count should not exceed limit") + relay.mu.RUnlock() +} + +// testObserver records UDP session lifecycle events for test assertions. +type testObserver struct { + mu sync.Mutex + started int + ended int + rejected int + dialErr int + packets int + bytes int +} + +func (o *testObserver) UDPSessionStarted(types.AccountID) { o.mu.Lock(); o.started++; o.mu.Unlock() } +func (o *testObserver) UDPSessionEnded(types.AccountID) { o.mu.Lock(); o.ended++; o.mu.Unlock() } +func (o *testObserver) UDPSessionDialError(types.AccountID) { o.mu.Lock(); o.dialErr++; o.mu.Unlock() } +func (o *testObserver) UDPSessionRejected(types.AccountID) { o.mu.Lock(); o.rejected++; o.mu.Unlock() } +func (o *testObserver) UDPPacketRelayed(_ types.RelayDirection, b int) { + o.mu.Lock() + o.packets++ + o.bytes += b + o.mu.Unlock() +} + +func TestRelay_CloseFiresObserverEnded(t *testing.T) { + backend, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer backend.Close() + + go func() { + buf := make([]byte, 65535) + for { + n, addr, err := backend.ReadFrom(buf) + if err != nil { + return + } + _, _ = backend.WriteTo(buf[:n], addr) + } + }() + + listener, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer listener.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + logger := log.NewEntry(log.StandardLogger()) + dialFunc := func(ctx context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + } + + obs := &testObserver{} + relay := New(ctx, RelayConfig{Logger: logger, Listener: listener, Target: backend.LocalAddr().String(), AccountID: "test-acct", DialFunc: dialFunc}) + relay.SetObserver(obs) + go relay.Serve() + + // Create two sessions. + for i := range 2 { + client, err := net.Dial("udp", listener.LocalAddr().String()) + require.NoError(t, err, "client %d", i) + + _, err = client.Write([]byte("hello")) + require.NoError(t, err) + + require.NoError(t, client.SetReadDeadline(time.Now().Add(2*time.Second))) + buf := make([]byte, 1024) + _, err = client.Read(buf) + require.NoError(t, err) + client.Close() + } + + obs.mu.Lock() + assert.Equal(t, 2, obs.started, "should have 2 started events") + obs.mu.Unlock() + + // Close should fire UDPSessionEnded for all remaining sessions. + relay.Close() + + obs.mu.Lock() + assert.Equal(t, 2, obs.ended, "Close should fire UDPSessionEnded for each session") + obs.mu.Unlock() +} + +func TestRelay_SessionRateLimit(t *testing.T) { + backend, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer backend.Close() + + go func() { + buf := make([]byte, 65535) + for { + n, addr, err := backend.ReadFrom(buf) + if err != nil { + return + } + _, _ = backend.WriteTo(buf[:n], addr) + } + }() + + listener, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer listener.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + logger := log.NewEntry(log.StandardLogger()) + dialFunc := func(ctx context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + } + + obs := &testObserver{} + // High max sessions (1000) but the relay uses a rate limiter internally + // (default: 50/s burst 100). We exhaust the burst by creating sessions + // rapidly, then verify that subsequent creates are rejected. + relay := New(ctx, RelayConfig{Logger: logger, Listener: listener, Target: backend.LocalAddr().String(), AccountID: "test-acct", DialFunc: dialFunc, MaxSessions: 1000}) + relay.SetObserver(obs) + go relay.Serve() + defer relay.Close() + + // Exhaust the burst by calling getOrCreateSession directly with + // synthetic addresses. This is faster than real UDP round-trips. + for i := range sessionCreateBurst + 20 { + addr := &net.UDPAddr{IP: net.IPv4(10, 0, byte(i/256), byte(i%256)), Port: 10000 + i} + _, _ = relay.getOrCreateSession(addr) + } + + obs.mu.Lock() + rejected := obs.rejected + obs.mu.Unlock() + + assert.Greater(t, rejected, 0, "some sessions should be rate-limited") +} diff --git a/proxy/log.go b/proxy/log.go new file mode 100644 index 000000000..79562989e --- /dev/null +++ b/proxy/log.go @@ -0,0 +1,21 @@ +package proxy + +import ( + stdlog "log" + + log "github.com/sirupsen/logrus" +) + +const ( + // HTTP server type identifiers for logging + logtagFieldHTTPServer = "http-server" + logtagValueHTTPS = "https" + logtagValueACME = "acme" + logtagValueDebug = "debug" +) + +// newHTTPServerLogger creates a standard library logger that writes to logrus +// with the specified server type field. +func newHTTPServerLogger(logger *log.Logger, serverType string) *stdlog.Logger { + return stdlog.New(logger.WithField(logtagFieldHTTPServer, serverType).WriterLevel(log.WarnLevel), "", 0) +} diff --git a/proxy/management_integration_test.go b/proxy/management_integration_test.go new file mode 100644 index 000000000..8af151446 --- /dev/null +++ b/proxy/management_integration_test.go @@ -0,0 +1,641 @@ +package proxy + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "errors" + "net" + "sync" + "sync/atomic" + "testing" + "time" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + nbproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/management/server/users" + "github.com/netbirdio/netbird/proxy/internal/auth" + "github.com/netbirdio/netbird/proxy/internal/proxy" + proxytypes "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// integrationTestSetup contains all real components for testing. +type integrationTestSetup struct { + store store.Store + proxyService *nbgrpc.ProxyServiceServer + grpcServer *grpc.Server + grpcAddr string + cleanup func() + services []*service.Service +} + +func setupIntegrationTest(t *testing.T) *integrationTestSetup { + t.Helper() + + ctx := context.Background() + + // Create real SQLite store + testStore, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err) + + // Create test account + testAccount := &types.Account{ + Id: "test-account-1", + Domain: "test.com", + DomainCategory: "private", + IsDomainPrimaryAccount: true, + CreatedAt: time.Now(), + } + require.NoError(t, testStore.SaveAccount(ctx, testAccount)) + + // Generate session keys for reverse proxies + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + pubKey := base64.StdEncoding.EncodeToString(pub) + privKey := base64.StdEncoding.EncodeToString(priv) + + // Create test services in the store + services := []*service.Service{ + { + ID: "rp-1", + AccountID: "test-account-1", + Name: "Test App 1", + Domain: "app1.test.proxy.io", + Targets: []*service.Target{{ + Path: strPtr("/"), + Host: "10.0.0.1", + Port: 8080, + Protocol: "http", + TargetId: "peer1", + TargetType: "peer", + Enabled: true, + }}, + Enabled: true, + ProxyCluster: "test.proxy.io", + SessionPrivateKey: privKey, + SessionPublicKey: pubKey, + }, + { + ID: "rp-2", + AccountID: "test-account-1", + Name: "Test App 2", + Domain: "app2.test.proxy.io", + Targets: []*service.Target{{ + Path: strPtr("/"), + Host: "10.0.0.2", + Port: 8080, + Protocol: "http", + TargetId: "peer2", + TargetType: "peer", + Enabled: true, + }}, + Enabled: true, + ProxyCluster: "test.proxy.io", + SessionPrivateKey: privKey, + SessionPublicKey: pubKey, + }, + } + + for _, svc := range services { + require.NoError(t, testStore.CreateService(ctx, svc)) + } + + // Create real token store + tokenStore, err := nbgrpc.NewOneTimeTokenStore(ctx, 5*time.Minute, 10*time.Minute, 100) + require.NoError(t, err) + + pkceStore, err := nbgrpc.NewPKCEVerifierStore(ctx, 10*time.Minute, 10*time.Minute, 100) + require.NoError(t, err) + + // Create real users manager + usersManager := users.NewManager(testStore) + + // Create real proxy service server with minimal config + oidcConfig := nbgrpc.ProxyOIDCConfig{ + Issuer: "https://fake-issuer.example.com", + ClientID: "test-client", + HMACKey: []byte("test-hmac-key"), + } + + proxyManager := &testProxyManager{} + + proxyService := nbgrpc.NewProxyServiceServer( + &testAccessLogManager{}, + tokenStore, + pkceStore, + oidcConfig, + nil, + usersManager, + proxyManager, + ) + + // Use store-backed service manager + svcMgr := &storeBackedServiceManager{store: testStore, tokenStore: tokenStore} + proxyService.SetServiceManager(svcMgr) + + proxyController := &testProxyController{} + proxyService.SetProxyController(proxyController) + + // Start real gRPC server + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + grpcServer := grpc.NewServer() + proto.RegisterProxyServiceServer(grpcServer, proxyService) + + go func() { + if err := grpcServer.Serve(lis); err != nil { + t.Logf("gRPC server error: %v", err) + } + }() + + return &integrationTestSetup{ + store: testStore, + proxyService: proxyService, + grpcServer: grpcServer, + grpcAddr: lis.Addr().String(), + services: services, + cleanup: func() { + grpcServer.GracefulStop() + cleanup() + }, + } +} + +// testAccessLogManager provides access log storage for testing. +type testAccessLogManager struct{} + +func (m *testAccessLogManager) CleanupOldAccessLogs(ctx context.Context, retentionDays int) (int64, error) { + return 0, nil +} + +func (m *testAccessLogManager) StartPeriodicCleanup(ctx context.Context, retentionDays, cleanupIntervalHours int) { + // noop +} + +func (m *testAccessLogManager) StopPeriodicCleanup() { + // noop +} + +func (m *testAccessLogManager) SaveAccessLog(_ context.Context, _ *accesslogs.AccessLogEntry) error { + return nil +} + +func (m *testAccessLogManager) GetAllAccessLogs(_ context.Context, _, _ string, _ *accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) { + return nil, 0, nil +} + +// testProxyManager is a mock implementation of proxy.Manager for testing. +type testProxyManager struct{} + +func (m *testProxyManager) Connect(_ context.Context, _, _, _ string) error { + return nil +} + +func (m *testProxyManager) Disconnect(_ context.Context, _ string) error { + return nil +} + +func (m *testProxyManager) Heartbeat(_ context.Context, _ string) error { + return nil +} + +func (m *testProxyManager) GetActiveClusterAddresses(_ context.Context) ([]string, error) { + return nil, nil +} + +func (m *testProxyManager) CleanupStale(_ context.Context, _ time.Duration) error { + return nil +} + +// testProxyController is a mock implementation of rpservice.ProxyController for testing. +type testProxyController struct{} + +func (c *testProxyController) SendServiceUpdateToCluster(_ context.Context, _ string, _ *proto.ProxyMapping, _ string) { + // noop +} + +func (c *testProxyController) GetOIDCValidationConfig() nbproxy.OIDCValidationConfig { + return nbproxy.OIDCValidationConfig{} +} + +func (c *testProxyController) RegisterProxyToCluster(_ context.Context, _, _ string) error { + return nil +} + +func (c *testProxyController) UnregisterProxyFromCluster(_ context.Context, _, _ string) error { + return nil +} + +func (c *testProxyController) GetProxiesForCluster(_ string) []string { + return nil +} + +func (c *testProxyController) ClusterSupportsCustomPorts(_ string) *bool { + return nil +} + +// storeBackedServiceManager reads directly from the real store. +type storeBackedServiceManager struct { + store store.Store + tokenStore *nbgrpc.OneTimeTokenStore +} + +func (m *storeBackedServiceManager) DeleteAllServices(ctx context.Context, accountID, userID string) error { + return nil +} + +func (m *storeBackedServiceManager) GetAllServices(ctx context.Context, accountID, userID string) ([]*service.Service, error) { + return m.store.GetAccountServices(ctx, store.LockingStrengthNone, accountID) +} + +func (m *storeBackedServiceManager) GetService(ctx context.Context, accountID, userID, serviceID string) (*service.Service, error) { + return m.store.GetServiceByID(ctx, store.LockingStrengthNone, accountID, serviceID) +} + +func (m *storeBackedServiceManager) CreateService(_ context.Context, _, _ string, _ *service.Service) (*service.Service, error) { + return nil, errors.New("not implemented") +} + +func (m *storeBackedServiceManager) UpdateService(_ context.Context, _, _ string, _ *service.Service) (*service.Service, error) { + return nil, errors.New("not implemented") +} + +func (m *storeBackedServiceManager) DeleteService(ctx context.Context, accountID, userID, serviceID string) error { + return nil +} + +func (m *storeBackedServiceManager) SetCertificateIssuedAt(ctx context.Context, accountID, serviceID string) error { + return nil +} + +func (m *storeBackedServiceManager) SetStatus(ctx context.Context, accountID, serviceID string, status service.Status) error { + return nil +} + +func (m *storeBackedServiceManager) ReloadAllServicesForAccount(ctx context.Context, accountID string) error { + return nil +} + +func (m *storeBackedServiceManager) ReloadService(ctx context.Context, accountID, serviceID string) error { + return nil +} + +func (m *storeBackedServiceManager) GetGlobalServices(ctx context.Context) ([]*service.Service, error) { + return m.store.GetAccountServices(ctx, store.LockingStrengthNone, "test-account-1") +} + +func (m *storeBackedServiceManager) GetServiceByID(ctx context.Context, accountID, serviceID string) (*service.Service, error) { + return m.store.GetServiceByID(ctx, store.LockingStrengthNone, accountID, serviceID) +} + +func (m *storeBackedServiceManager) GetAccountServices(ctx context.Context, accountID string) ([]*service.Service, error) { + return m.store.GetAccountServices(ctx, store.LockingStrengthNone, accountID) +} + +func (m *storeBackedServiceManager) GetServiceIDByTargetID(ctx context.Context, accountID string, targetID string) (string, error) { + return "", nil +} + +func (m *storeBackedServiceManager) CreateServiceFromPeer(_ context.Context, _, _ string, _ *service.ExposeServiceRequest) (*service.ExposeServiceResponse, error) { + return &service.ExposeServiceResponse{}, nil +} + +func (m *storeBackedServiceManager) RenewServiceFromPeer(_ context.Context, _, _, _ string) error { + return nil +} + +func (m *storeBackedServiceManager) StopServiceFromPeer(_ context.Context, _, _, _ string) error { + return nil +} + +func (m *storeBackedServiceManager) StartExposeReaper(_ context.Context) {} + +func strPtr(s string) *string { + return &s +} + +func TestIntegration_ProxyConnection_HappyPath(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer conn.Close() + + client := proto.NewProxyServiceClient(conn) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + stream, err := client.GetMappingUpdate(ctx, &proto.GetMappingUpdateRequest{ + ProxyId: "test-proxy-1", + Version: "test-v1", + Address: "test.proxy.io", + }) + require.NoError(t, err) + + // Receive all mappings from the snapshot - server sends each mapping individually + mappingsByID := make(map[string]*proto.ProxyMapping) + for i := 0; i < 2; i++ { + msg, err := stream.Recv() + require.NoError(t, err) + for _, m := range msg.GetMapping() { + mappingsByID[m.GetId()] = m + } + } + + // Should receive 2 mappings total + assert.Len(t, mappingsByID, 2, "Should receive 2 reverse proxy mappings") + + rp1 := mappingsByID["rp-1"] + require.NotNil(t, rp1) + assert.Equal(t, "app1.test.proxy.io", rp1.GetDomain()) + assert.Equal(t, "test-account-1", rp1.GetAccountId()) + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, rp1.GetType()) + assert.NotEmpty(t, rp1.GetAuthToken(), "Should have auth token for peer creation") + + rp2 := mappingsByID["rp-2"] + require.NotNil(t, rp2) + assert.Equal(t, "app2.test.proxy.io", rp2.GetDomain()) +} + +func TestIntegration_ProxyConnection_SendsClusterAddress(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer conn.Close() + + client := proto.NewProxyServiceClient(conn) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + clusterAddress := "test.proxy.io" + + stream, err := client.GetMappingUpdate(ctx, &proto.GetMappingUpdateRequest{ + ProxyId: "test-proxy-cluster", + Version: "test-v1", + Address: clusterAddress, + }) + require.NoError(t, err) + + // Receive all mappings - server sends each mapping individually + mappings := make([]*proto.ProxyMapping, 0) + for i := 0; i < 2; i++ { + msg, err := stream.Recv() + require.NoError(t, err) + mappings = append(mappings, msg.GetMapping()...) + } + + // Should receive the 2 mappings matching the cluster + assert.Len(t, mappings, 2, "Should receive mappings for the cluster") + + for _, mapping := range mappings { + t.Logf("Received mapping: id=%s domain=%s", mapping.GetId(), mapping.GetDomain()) + } +} + +func TestIntegration_ProxyConnection_Reconnect_ReceivesSameConfig(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer conn.Close() + + client := proto.NewProxyServiceClient(conn) + + clusterAddress := "test.proxy.io" + proxyID := "test-proxy-reconnect" + + // Helper to receive all mappings from a stream + receiveMappings := func(stream proto.ProxyService_GetMappingUpdateClient, count int) []*proto.ProxyMapping { + var mappings []*proto.ProxyMapping + for i := 0; i < count; i++ { + msg, err := stream.Recv() + require.NoError(t, err) + mappings = append(mappings, msg.GetMapping()...) + } + return mappings + } + + // First connection + ctx1, cancel1 := context.WithTimeout(context.Background(), 5*time.Second) + stream1, err := client.GetMappingUpdate(ctx1, &proto.GetMappingUpdateRequest{ + ProxyId: proxyID, + Version: "test-v1", + Address: clusterAddress, + }) + require.NoError(t, err) + + firstMappings := receiveMappings(stream1, 2) + cancel1() + + time.Sleep(100 * time.Millisecond) + + // Second connection (simulating reconnect) + ctx2, cancel2 := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel2() + + stream2, err := client.GetMappingUpdate(ctx2, &proto.GetMappingUpdateRequest{ + ProxyId: proxyID, + Version: "test-v1", + Address: clusterAddress, + }) + require.NoError(t, err) + + secondMappings := receiveMappings(stream2, 2) + + // Should receive the same mappings + assert.Equal(t, len(firstMappings), len(secondMappings), + "Should receive same number of mappings on reconnect") + + firstIDs := make(map[string]bool) + for _, m := range firstMappings { + firstIDs[m.GetId()] = true + } + + for _, m := range secondMappings { + assert.True(t, firstIDs[m.GetId()], + "Mapping %s should be present in both connections", m.GetId()) + } +} + +func TestIntegration_ProxyConnection_ReconnectDoesNotDuplicateState(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer conn.Close() + + client := proto.NewProxyServiceClient(conn) + + // Use real auth middleware and proxy to verify idempotency + logger := log.New() + logger.SetLevel(log.WarnLevel) + + authMw := auth.NewMiddleware(logger, nil, nil) + proxyHandler := proxy.NewReverseProxy(nil, "auto", nil, logger) + + clusterAddress := "test.proxy.io" + proxyID := "test-proxy-idempotent" + + var addMappingCalls atomic.Int32 + + applyMappings := func(mappings []*proto.ProxyMapping) { + for _, mapping := range mappings { + if mapping.GetType() == proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED { + addMappingCalls.Add(1) + + // Apply to real auth middleware (idempotent) + err := authMw.AddDomain( + mapping.GetDomain(), + nil, + "", + 0, + proxytypes.AccountID(mapping.GetAccountId()), + proxytypes.ServiceID(mapping.GetId()), + nil, + ) + require.NoError(t, err) + + // Apply to real proxy (idempotent) + proxyHandler.AddMapping(proxy.Mapping{ + Host: mapping.GetDomain(), + ID: proxytypes.ServiceID(mapping.GetId()), + AccountID: proxytypes.AccountID(mapping.GetAccountId()), + }) + } + } + } + + // Helper to receive and apply all mappings + receiveAndApply := func(stream proto.ProxyService_GetMappingUpdateClient) { + for i := 0; i < 2; i++ { + msg, err := stream.Recv() + require.NoError(t, err) + applyMappings(msg.GetMapping()) + } + } + + // First connection + ctx1, cancel1 := context.WithTimeout(context.Background(), 5*time.Second) + stream1, err := client.GetMappingUpdate(ctx1, &proto.GetMappingUpdateRequest{ + ProxyId: proxyID, + Version: "test-v1", + Address: clusterAddress, + }) + require.NoError(t, err) + + receiveAndApply(stream1) + cancel1() + + firstCallCount := addMappingCalls.Load() + t.Logf("First connection: applied %d mappings", firstCallCount) + + time.Sleep(100 * time.Millisecond) + + // Second connection + ctx2, cancel2 := context.WithTimeout(context.Background(), 5*time.Second) + stream2, err := client.GetMappingUpdate(ctx2, &proto.GetMappingUpdateRequest{ + ProxyId: proxyID, + Version: "test-v1", + Address: clusterAddress, + }) + require.NoError(t, err) + + receiveAndApply(stream2) + cancel2() + + time.Sleep(100 * time.Millisecond) + + // Third connection + ctx3, cancel3 := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel3() + + stream3, err := client.GetMappingUpdate(ctx3, &proto.GetMappingUpdateRequest{ + ProxyId: proxyID, + Version: "test-v1", + Address: clusterAddress, + }) + require.NoError(t, err) + + receiveAndApply(stream3) + + totalCalls := addMappingCalls.Load() + t.Logf("After three connections: total applied %d mappings", totalCalls) + + // Should have called addMapping 6 times (2 mappings x 3 connections) + // But internal state is NOT duplicated because auth and proxy use maps keyed by domain/host + assert.Equal(t, int32(6), totalCalls, "Should have 6 total calls (2 mappings x 3 connections)") +} + +func TestIntegration_ProxyConnection_MultipleProxiesReceiveUpdates(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + clusterAddress := "test.proxy.io" + + var wg sync.WaitGroup + var mu sync.Mutex + receivedByProxy := make(map[string]int) + + for i := 1; i <= 3; i++ { + wg.Add(1) + go func(proxyNum int) { + defer wg.Done() + + conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer conn.Close() + + client := proto.NewProxyServiceClient(conn) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + proxyID := "test-proxy-" + string(rune('A'+proxyNum-1)) + + stream, err := client.GetMappingUpdate(ctx, &proto.GetMappingUpdateRequest{ + ProxyId: proxyID, + Version: "test-v1", + Address: clusterAddress, + }) + require.NoError(t, err) + + // Receive all mappings - server sends each mapping individually + count := 0 + for i := 0; i < 2; i++ { + msg, err := stream.Recv() + require.NoError(t, err) + count += len(msg.GetMapping()) + } + + mu.Lock() + receivedByProxy[proxyID] = count + mu.Unlock() + }(i) + } + + wg.Wait() + + for proxyID, count := range receivedByProxy { + assert.Equal(t, 2, count, "Proxy %s should receive 2 mappings", proxyID) + } +} diff --git a/proxy/proxyprotocol_test.go b/proxy/proxyprotocol_test.go new file mode 100644 index 000000000..fe2fe7e2d --- /dev/null +++ b/proxy/proxyprotocol_test.go @@ -0,0 +1,106 @@ +package proxy + +import ( + "net" + "net/netip" + "testing" + "time" + + proxyproto "github.com/pires/go-proxyproto" + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWrapProxyProtocol_OverridesRemoteAddr(t *testing.T) { + srv := &Server{ + Logger: log.StandardLogger(), + TrustedProxies: []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")}, + ProxyProtocol: true, + } + + raw, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer raw.Close() + + ln := srv.wrapProxyProtocol(raw) + + realClientIP := "203.0.113.50" + realClientPort := uint16(54321) + + accepted := make(chan net.Conn, 1) + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + accepted <- conn + }() + + // Connect and send a PROXY v2 header. + conn, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + defer conn.Close() + + header := &proxyproto.Header{ + Version: 2, + Command: proxyproto.PROXY, + TransportProtocol: proxyproto.TCPv4, + SourceAddr: &net.TCPAddr{IP: net.ParseIP(realClientIP), Port: int(realClientPort)}, + DestinationAddr: &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 443}, + } + _, err = header.WriteTo(conn) + require.NoError(t, err) + + select { + case accepted := <-accepted: + defer accepted.Close() + host, _, err := net.SplitHostPort(accepted.RemoteAddr().String()) + require.NoError(t, err) + assert.Equal(t, realClientIP, host, "RemoteAddr should reflect the PROXY header source IP") + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for connection") + } +} + +func TestProxyProtocolPolicy_TrustedRequires(t *testing.T) { + srv := &Server{ + Logger: log.StandardLogger(), + TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}, + } + + opts := proxyproto.ConnPolicyOptions{ + Upstream: &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 1234}, + } + policy, err := srv.proxyProtocolPolicy(opts) + require.NoError(t, err) + assert.Equal(t, proxyproto.REQUIRE, policy, "trusted source should require PROXY header") +} + +func TestProxyProtocolPolicy_UntrustedIgnores(t *testing.T) { + srv := &Server{ + Logger: log.StandardLogger(), + TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}, + } + + opts := proxyproto.ConnPolicyOptions{ + Upstream: &net.TCPAddr{IP: net.ParseIP("203.0.113.50"), Port: 1234}, + } + policy, err := srv.proxyProtocolPolicy(opts) + require.NoError(t, err) + assert.Equal(t, proxyproto.IGNORE, policy, "untrusted source should have PROXY header ignored") +} + +func TestProxyProtocolPolicy_InvalidIPRejects(t *testing.T) { + srv := &Server{ + Logger: log.StandardLogger(), + TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}, + } + + opts := proxyproto.ConnPolicyOptions{ + Upstream: &net.UnixAddr{Name: "/tmp/test.sock", Net: "unix"}, + } + policy, err := srv.proxyProtocolPolicy(opts) + require.NoError(t, err) + assert.Equal(t, proxyproto.REJECT, policy, "unparsable address should be rejected") +} diff --git a/proxy/server.go b/proxy/server.go new file mode 100644 index 000000000..c4d12859b --- /dev/null +++ b/proxy/server.go @@ -0,0 +1,1601 @@ +// Package proxy runs a NetBird proxy server. +// It attempts to do everything it needs to do within the context +// of a single request to the server to try to reduce the amount +// of concurrency coordination that is required. However, it does +// run two additional routines in an error group for handling +// updates from the management server and running a separate +// HTTP server to handle ACME HTTP-01 challenges (if configured). +package proxy + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "net/url" + "path/filepath" + "reflect" + "sync" + "time" + + "github.com/cenkalti/backoff/v4" + "github.com/pires/go-proxyproto" + prometheus2 "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + log "github.com/sirupsen/logrus" + "go.opentelemetry.io/otel/exporters/prometheus" + "go.opentelemetry.io/otel/sdk/metric" + "golang.org/x/exp/maps" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/keepalive" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/netbirdio/netbird/proxy/internal/accesslog" + "github.com/netbirdio/netbird/proxy/internal/acme" + "github.com/netbirdio/netbird/proxy/internal/auth" + "github.com/netbirdio/netbird/proxy/internal/certwatch" + "github.com/netbirdio/netbird/proxy/internal/conntrack" + "github.com/netbirdio/netbird/proxy/internal/debug" + "github.com/netbirdio/netbird/proxy/internal/geolocation" + proxygrpc "github.com/netbirdio/netbird/proxy/internal/grpc" + "github.com/netbirdio/netbird/proxy/internal/health" + "github.com/netbirdio/netbird/proxy/internal/k8s" + proxymetrics "github.com/netbirdio/netbird/proxy/internal/metrics" + "github.com/netbirdio/netbird/proxy/internal/netutil" + "github.com/netbirdio/netbird/proxy/internal/proxy" + "github.com/netbirdio/netbird/proxy/internal/restrict" + "github.com/netbirdio/netbird/proxy/internal/roundtrip" + nbtcp "github.com/netbirdio/netbird/proxy/internal/tcp" + "github.com/netbirdio/netbird/proxy/internal/types" + udprelay "github.com/netbirdio/netbird/proxy/internal/udp" + "github.com/netbirdio/netbird/proxy/web" + "github.com/netbirdio/netbird/shared/management/domain" + "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/util/embeddedroots" +) + +// portRouter bundles a per-port Router with its listener and cancel func. +type portRouter struct { + router *nbtcp.Router + listener net.Listener + cancel context.CancelFunc +} + +type Server struct { + mgmtClient proto.ProxyServiceClient + proxy *proxy.ReverseProxy + netbird *roundtrip.NetBird + acme *acme.Manager + auth *auth.Middleware + http *http.Server + https *http.Server + debug *http.Server + healthServer *health.Server + healthChecker *health.Checker + meter *proxymetrics.Metrics + accessLog *accesslog.Logger + mainRouter *nbtcp.Router + mainPort uint16 + udpMu sync.Mutex + udpRelays map[types.ServiceID]*udprelay.Relay + udpRelayWg sync.WaitGroup + portMu sync.RWMutex + portRouters map[uint16]*portRouter + svcPorts map[types.ServiceID][]uint16 + lastMappings map[types.ServiceID]*proto.ProxyMapping + portRouterWg sync.WaitGroup + + // hijackTracker tracks hijacked connections (e.g. WebSocket upgrades) + // so they can be closed during graceful shutdown, since http.Server.Shutdown + // does not handle them. + hijackTracker conntrack.HijackTracker + // geo resolves IP addresses to country/city for access restrictions and access logs. + geo restrict.GeoResolver + geoRaw *geolocation.Lookup + + // routerReady is closed once mainRouter is fully initialized. + // The mapping worker waits on this before processing updates. + routerReady chan struct{} + + // Mostly used for debugging on management. + startTime time.Time + + ID string + Logger *log.Logger + Version string + ProxyURL string + ManagementAddress string + CertificateDirectory string + CertificateFile string + CertificateKeyFile string + GenerateACMECertificates bool + ACMEChallengeAddress string + ACMEDirectory string + // ACMEEABKID is the External Account Binding Key ID for CAs that require EAB (e.g., ZeroSSL). + ACMEEABKID string + // ACMEEABHMACKey is the External Account Binding HMAC key (base64 URL-encoded) for CAs that require EAB. + ACMEEABHMACKey string + // ACMEChallengeType specifies the ACME challenge type: "http-01" or "tls-alpn-01". + // Defaults to "tls-alpn-01" if not specified. + ACMEChallengeType string + // CertLockMethod controls how ACME certificate locks are coordinated + // across replicas. Default: CertLockAuto (detect environment). + CertLockMethod acme.CertLockMethod + // WildcardCertDir is an optional directory containing wildcard certificate + // pairs (.crt / .key). Wildcard patterns are extracted from + // the certificates' SAN lists. Matching domains use these static certs + // instead of ACME. + WildcardCertDir string + + // DebugEndpointEnabled enables the debug HTTP endpoint. + DebugEndpointEnabled bool + // DebugEndpointAddress is the address for the debug HTTP endpoint (default: ":8444"). + DebugEndpointAddress string + // HealthAddress is the address for the health probe endpoint. + HealthAddress string + // ProxyToken is the access token for authenticating with the management server. + ProxyToken string + // ForwardedProto overrides the X-Forwarded-Proto value sent to backends. + // Valid values: "auto" (detect from TLS), "http", "https". + ForwardedProto string + // TrustedProxies is a list of IP prefixes for trusted upstream proxies. + // When set, forwarding headers from these sources are preserved and + // appended to instead of being stripped. + TrustedProxies []netip.Prefix + // WireguardPort is the port for the NetBird tunnel interface. Use 0 + // for a random OS-assigned port. A fixed port only works with + // single-account deployments; multiple accounts will fail to bind + // the same port. + WireguardPort uint16 + // ProxyProtocol enables PROXY protocol (v1/v2) on TCP listeners. + // When enabled, the real client IP is extracted from the PROXY header + // sent by upstream L4 proxies that support PROXY protocol. + ProxyProtocol bool + // PreSharedKey used for tunnel between proxy and peers (set globally not per account) + PreSharedKey string + // SupportsCustomPorts indicates whether the proxy can bind arbitrary + // ports for TCP/UDP/TLS services. + SupportsCustomPorts bool + // MaxDialTimeout caps the per-service backend dial timeout. + // When the API sends a timeout, it is clamped to this value. + // When the API sends no timeout, this value is used as the default. + // Zero means no cap (the proxy honors whatever management sends). + MaxDialTimeout time.Duration + // GeoDataDir is the directory containing GeoLite2 MMDB files for + // country-based access restrictions. Empty disables geo lookups. + GeoDataDir string + // MaxSessionIdleTimeout caps the per-service session idle timeout. + // Zero means no cap (the proxy honors whatever management sends). + // Set via NB_PROXY_MAX_SESSION_IDLE_TIMEOUT for shared deployments. + MaxSessionIdleTimeout time.Duration +} + +// clampIdleTimeout returns d capped to MaxSessionIdleTimeout when configured. +func (s *Server) clampIdleTimeout(d time.Duration) time.Duration { + if s.MaxSessionIdleTimeout > 0 && d > s.MaxSessionIdleTimeout { + return s.MaxSessionIdleTimeout + } + return d +} + +// clampDialTimeout returns d capped to MaxDialTimeout when configured. +// If d is zero, MaxDialTimeout is used as the default. +func (s *Server) clampDialTimeout(d time.Duration) time.Duration { + if s.MaxDialTimeout <= 0 { + return d + } + if d <= 0 || d > s.MaxDialTimeout { + return s.MaxDialTimeout + } + return d +} + +// NotifyStatus sends a status update to management about tunnel connectivity. +func (s *Server) NotifyStatus(ctx context.Context, accountID types.AccountID, serviceID types.ServiceID, connected bool) error { + status := proto.ProxyStatus_PROXY_STATUS_TUNNEL_NOT_CREATED + if connected { + status = proto.ProxyStatus_PROXY_STATUS_ACTIVE + } + + _, err := s.mgmtClient.SendStatusUpdate(ctx, &proto.SendStatusUpdateRequest{ + ServiceId: string(serviceID), + AccountId: string(accountID), + Status: status, + CertificateIssued: false, + }) + return err +} + +// NotifyCertificateIssued sends a notification to management that a certificate was issued +func (s *Server) NotifyCertificateIssued(ctx context.Context, accountID types.AccountID, serviceID types.ServiceID, domain string) error { + _, err := s.mgmtClient.SendStatusUpdate(ctx, &proto.SendStatusUpdateRequest{ + ServiceId: string(serviceID), + AccountId: string(accountID), + Status: proto.ProxyStatus_PROXY_STATUS_ACTIVE, + CertificateIssued: true, + }) + return err +} + +func (s *Server) ListenAndServe(ctx context.Context, addr string) (err error) { + s.initDefaults() + s.routerReady = make(chan struct{}) + s.udpRelays = make(map[types.ServiceID]*udprelay.Relay) + s.portRouters = make(map[uint16]*portRouter) + s.svcPorts = make(map[types.ServiceID][]uint16) + s.lastMappings = make(map[types.ServiceID]*proto.ProxyMapping) + + exporter, err := prometheus.New() + if err != nil { + return fmt.Errorf("create prometheus exporter: %w", err) + } + + provider := metric.NewMeterProvider(metric.WithReader(exporter)) + pkg := reflect.TypeOf(Server{}).PkgPath() + meter := provider.Meter(pkg) + + s.meter, err = proxymetrics.New(ctx, meter) + if err != nil { + return fmt.Errorf("create metrics: %w", err) + } + + mgmtConn, err := s.dialManagement() + if err != nil { + return err + } + defer func() { + if err := mgmtConn.Close(); err != nil { + s.Logger.Debugf("management connection close: %v", err) + } + }() + s.mgmtClient = proto.NewProxyServiceClient(mgmtConn) + runCtx, runCancel := context.WithCancel(ctx) + defer runCancel() + + // Initialize the netbird client, this is required to build peer connections + // to proxy over. + s.netbird = roundtrip.NewNetBird(s.ID, s.ProxyURL, roundtrip.ClientConfig{ + MgmtAddr: s.ManagementAddress, + WGPort: s.WireguardPort, + PreSharedKey: s.PreSharedKey, + }, s.Logger, s, s.mgmtClient) + + // Create health checker before the mapping worker so it can track + // management connectivity from the first stream connection. + s.healthChecker = health.NewChecker(s.Logger, s.netbird) + + go s.newManagementMappingWorker(runCtx, s.mgmtClient) + + tlsConfig, err := s.configureTLS(ctx) + if err != nil { + return err + } + + // Configure the reverse proxy using NetBird's HTTP Client Transport for proxying. + s.proxy = proxy.NewReverseProxy(s.meter.RoundTripper(s.netbird), s.ForwardedProto, s.TrustedProxies, s.Logger) + + geoLookup, err := geolocation.NewLookup(s.Logger, s.GeoDataDir) + if err != nil { + return fmt.Errorf("initialize geolocation: %w", err) + } + s.geoRaw = geoLookup + if geoLookup != nil { + s.geo = geoLookup + } + + var startupOK bool + defer func() { + if startupOK { + return + } + if s.geoRaw != nil { + if err := s.geoRaw.Close(); err != nil { + s.Logger.Debugf("close geolocation on startup failure: %v", err) + } + } + }() + + // Configure the authentication middleware with session validator for OIDC group checks. + s.auth = auth.NewMiddleware(s.Logger, s.mgmtClient, s.geo) + + // Configure Access logs to management server. + s.accessLog = accesslog.NewLogger(s.mgmtClient, s.Logger, s.TrustedProxies) + + s.startDebugEndpoint() + + if err := s.startHealthServer(); err != nil { + return err + } + + // Build the handler chain from inside out. + handler := http.Handler(s.proxy) + handler = s.auth.Protect(handler) + handler = web.AssetHandler(handler) + handler = s.accessLog.Middleware(handler) + handler = s.meter.Middleware(handler) + handler = s.hijackTracker.Middleware(handler) + + // Start a raw TCP listener; the SNI router peeks at ClientHello + // and routes to either the HTTP handler or a TCP relay. + lc := net.ListenConfig{} + ln, err := lc.Listen(ctx, "tcp", addr) + if err != nil { + return fmt.Errorf("listen on %s: %w", addr, err) + } + if s.ProxyProtocol { + ln = s.wrapProxyProtocol(ln) + } + s.mainPort = uint16(ln.Addr().(*net.TCPAddr).Port) //nolint:gosec // port from OS is always valid + + // Set up the SNI router for TCP/HTTP multiplexing on the main port. + s.mainRouter = nbtcp.NewRouter(s.Logger, s.resolveDialFunc, ln.Addr()) + s.mainRouter.SetObserver(s.meter) + s.mainRouter.SetAccessLogger(s.accessLog) + close(s.routerReady) + + // The HTTP server uses the chanListener fed by the SNI router. + s.https = &http.Server{ + Addr: addr, + Handler: handler, + TLSConfig: tlsConfig, + ReadHeaderTimeout: httpReadHeaderTimeout, + IdleTimeout: httpIdleTimeout, + ErrorLog: newHTTPServerLogger(s.Logger, logtagValueHTTPS), + } + + startupOK = true + + httpsErr := make(chan error, 1) + go func() { + s.Logger.Debug("starting HTTPS server on SNI router HTTP channel") + httpsErr <- s.https.ServeTLS(s.mainRouter.HTTPListener(), "", "") + }() + + routerErr := make(chan error, 1) + go func() { + s.Logger.Debugf("starting SNI router on %s", addr) + routerErr <- s.mainRouter.Serve(runCtx, ln) + }() + + select { + case err := <-httpsErr: + s.shutdownServices() + if !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("https server: %w", err) + } + return nil + case err := <-routerErr: + s.shutdownServices() + if err != nil { + return fmt.Errorf("SNI router: %w", err) + } + return nil + case <-ctx.Done(): + s.gracefulShutdown() + return nil + } +} + +// initDefaults sets fallback values for optional Server fields. +func (s *Server) initDefaults() { + s.startTime = time.Now() + + // If no ID is set then one can be generated. + if s.ID == "" { + s.ID = "netbird-proxy-" + s.startTime.Format("20060102150405") + } + // Fallback version option in case it is not set. + if s.Version == "" { + s.Version = "dev" + } + + // If no logger is specified fallback to the standard logger. + if s.Logger == nil { + s.Logger = log.StandardLogger() + } +} + +// startDebugEndpoint launches the debug HTTP server if enabled. +func (s *Server) startDebugEndpoint() { + if !s.DebugEndpointEnabled { + return + } + debugAddr := debugEndpointAddr(s.DebugEndpointAddress) + debugHandler := debug.NewHandler(s.netbird, s.healthChecker, s.Logger) + if s.acme != nil { + debugHandler.SetCertStatus(s.acme) + } + s.debug = &http.Server{ + Addr: debugAddr, + Handler: debugHandler, + ErrorLog: newHTTPServerLogger(s.Logger, logtagValueDebug), + } + go func() { + s.Logger.Infof("starting debug endpoint on %s", debugAddr) + if err := s.debug.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + s.Logger.Errorf("debug endpoint error: %v", err) + } + }() +} + +// startHealthServer launches the health probe and metrics server. +func (s *Server) startHealthServer() error { + healthAddr := s.HealthAddress + if healthAddr == "" { + healthAddr = defaultHealthAddr + } + s.healthServer = health.NewServer(healthAddr, s.healthChecker, s.Logger, promhttp.HandlerFor(prometheus2.DefaultGatherer, promhttp.HandlerOpts{EnableOpenMetrics: true})) + healthListener, err := net.Listen("tcp", healthAddr) + if err != nil { + return fmt.Errorf("health probe server listen on %s: %w", healthAddr, err) + } + go func() { + if err := s.healthServer.Serve(healthListener); err != nil && !errors.Is(err, http.ErrServerClosed) { + s.Logger.Errorf("health probe server: %v", err) + } + }() + return nil +} + +// wrapProxyProtocol wraps a listener with PROXY protocol support. +// When TrustedProxies is configured, only those sources may send PROXY headers; +// connections from untrusted sources have any PROXY header ignored. +func (s *Server) wrapProxyProtocol(ln net.Listener) net.Listener { + ppListener := &proxyproto.Listener{ + Listener: ln, + ReadHeaderTimeout: proxyProtoHeaderTimeout, + } + if len(s.TrustedProxies) > 0 { + ppListener.ConnPolicy = s.proxyProtocolPolicy + } else { + s.Logger.Warn("PROXY protocol enabled without trusted proxies; any source may send PROXY headers") + } + s.Logger.Info("PROXY protocol enabled on listener") + return ppListener +} + +// proxyProtocolPolicy returns whether to require, skip, or reject the PROXY +// header based on whether the connection source is in TrustedProxies. +func (s *Server) proxyProtocolPolicy(opts proxyproto.ConnPolicyOptions) (proxyproto.Policy, error) { + // No logging on reject to prevent abuse + tcpAddr, ok := opts.Upstream.(*net.TCPAddr) + if !ok { + return proxyproto.REJECT, nil + } + addr, ok := netip.AddrFromSlice(tcpAddr.IP) + if !ok { + return proxyproto.REJECT, nil + } + addr = addr.Unmap() + + // called per accept + for _, prefix := range s.TrustedProxies { + if prefix.Contains(addr) { + return proxyproto.REQUIRE, nil + } + } + return proxyproto.IGNORE, nil +} + +const ( + defaultHealthAddr = "localhost:8080" + defaultDebugAddr = "localhost:8444" + + // proxyProtoHeaderTimeout is the deadline for reading the PROXY protocol + // header after accepting a connection. + proxyProtoHeaderTimeout = 5 * time.Second + + // shutdownPreStopDelay is the time to wait after receiving a shutdown signal + // before draining connections. This allows the load balancer to propagate + // the endpoint removal. + shutdownPreStopDelay = 5 * time.Second + + // shutdownDrainTimeout is the maximum time to wait for in-flight HTTP + // requests to complete during graceful shutdown. + shutdownDrainTimeout = 30 * time.Second + + // shutdownServiceTimeout is the maximum time to wait for auxiliary + // services (health probe, debug endpoint, ACME) to shut down. + shutdownServiceTimeout = 5 * time.Second + + // httpReadHeaderTimeout limits how long the server waits to read + // request headers after accepting a connection. Prevents slowloris. + httpReadHeaderTimeout = 10 * time.Second + // httpIdleTimeout limits how long an idle keep-alive connection + // stays open before the server closes it. + httpIdleTimeout = 120 * time.Second +) + +func (s *Server) dialManagement() (*grpc.ClientConn, error) { + mgmtURL, err := url.Parse(s.ManagementAddress) + if err != nil { + return nil, fmt.Errorf("parse management address: %w", err) + } + creds := insecure.NewCredentials() + // Assume management TLS is enabled for gRPC as well if using HTTPS for the API. + if mgmtURL.Scheme == "https" { + certPool, err := x509.SystemCertPool() + if err != nil || certPool == nil { + // Fall back to embedded CAs if no OS-provided ones are available. + certPool = embeddedroots.Get() + } + creds = credentials.NewTLS(&tls.Config{ + RootCAs: certPool, + }) + } + s.Logger.WithFields(log.Fields{ + "gRPC_address": mgmtURL.Host, + "TLS_enabled": mgmtURL.Scheme == "https", + }).Debug("starting management gRPC client") + conn, err := grpc.NewClient(mgmtURL.Host, + grpc.WithTransportCredentials(creds), + grpc.WithKeepaliveParams(keepalive.ClientParameters{ + Time: 20 * time.Second, + Timeout: 10 * time.Second, + PermitWithoutStream: true, + }), + proxygrpc.WithProxyToken(s.ProxyToken), + ) + if err != nil { + return nil, fmt.Errorf("create management connection: %w", err) + } + return conn, nil +} + +func (s *Server) configureTLS(ctx context.Context) (*tls.Config, error) { + tlsConfig := &tls.Config{} + if !s.GenerateACMECertificates { + s.Logger.Debug("ACME certificates disabled, using static certificates with file watching") + certPath := filepath.Join(s.CertificateDirectory, s.CertificateFile) + keyPath := filepath.Join(s.CertificateDirectory, s.CertificateKeyFile) + + certWatcher, err := certwatch.NewWatcher(certPath, keyPath, s.Logger) + if err != nil { + return nil, fmt.Errorf("initialize certificate watcher: %w", err) + } + go certWatcher.Watch(ctx) + tlsConfig.GetCertificate = certWatcher.GetCertificate + return tlsConfig, nil + } + + if s.ACMEChallengeType == "" { + s.ACMEChallengeType = "tls-alpn-01" + } + s.Logger.WithFields(log.Fields{ + "acme_server": s.ACMEDirectory, + "challenge_type": s.ACMEChallengeType, + }).Debug("ACME certificates enabled, configuring certificate manager") + var err error + s.acme, err = acme.NewManager(acme.ManagerConfig{ + CertDir: s.CertificateDirectory, + ACMEURL: s.ACMEDirectory, + EABKID: s.ACMEEABKID, + EABHMACKey: s.ACMEEABHMACKey, + LockMethod: s.CertLockMethod, + WildcardDir: s.WildcardCertDir, + }, s, s.Logger, s.meter) + if err != nil { + return nil, fmt.Errorf("create ACME manager: %w", err) + } + + go s.acme.WatchWildcards(ctx) + + if s.ACMEChallengeType == "http-01" { + s.http = &http.Server{ + Addr: s.ACMEChallengeAddress, + Handler: s.acme.HTTPHandler(nil), + ErrorLog: newHTTPServerLogger(s.Logger, logtagValueACME), + } + go func() { + if err := s.http.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + s.Logger.WithError(err).Error("ACME HTTP-01 challenge server failed") + } + }() + } + tlsConfig = s.acme.TLSConfig() + + // autocert.Manager.TLSConfig() wires its own GetCertificate, which + // bypasses our override that checks wildcards first. + tlsConfig.GetCertificate = s.acme.GetCertificate + + // ServerName needs to be set to allow for ACME to work correctly + // when using CNAME URLs to access the proxy. + tlsConfig.ServerName = s.ProxyURL + + s.Logger.WithFields(log.Fields{ + "ServerName": s.ProxyURL, + "challenge_type": s.ACMEChallengeType, + }).Debug("ACME certificate manager configured") + return tlsConfig, nil +} + +// gracefulShutdown performs a zero-downtime shutdown sequence. It marks the +// readiness probe as failing, waits for load balancer propagation, drains +// in-flight connections, and then stops all background services. +func (s *Server) gracefulShutdown() { + s.Logger.Info("shutdown signal received, starting graceful shutdown") + + // Step 1: Fail readiness probe so load balancers stop routing new traffic. + if s.healthChecker != nil { + s.healthChecker.SetShuttingDown() + } + + // Step 2: When running behind a load balancer, wait for endpoint removal + // to propagate before draining connections. + if k8s.InCluster() { + s.Logger.Infof("waiting %s for load balancer propagation", shutdownPreStopDelay) + time.Sleep(shutdownPreStopDelay) + } + + // Step 3: Stop accepting new connections and drain in-flight requests. + drainCtx, drainCancel := context.WithTimeout(context.Background(), shutdownDrainTimeout) + defer drainCancel() + + s.Logger.Info("draining in-flight connections") + if err := s.https.Shutdown(drainCtx); err != nil { + s.Logger.Warnf("https server drain: %v", err) + } + + // Step 4: Close hijacked connections (WebSocket) that Shutdown does not handle. + if n := s.hijackTracker.CloseAll(); n > 0 { + s.Logger.Infof("closed %d hijacked connection(s)", n) + } + + // Drain all router relay connections (main + per-port) in parallel. + s.drainAllRouters(shutdownDrainTimeout) + + // Step 5: Stop all remaining background services. + s.shutdownServices() + s.Logger.Info("graceful shutdown complete") +} + +// shutdownServices stops all background services concurrently and waits for +// them to finish. +// drainAllRouters drains active relay connections on the main router and +// all per-port routers in parallel, up to the given timeout. +func (s *Server) drainAllRouters(timeout time.Duration) { + var wg sync.WaitGroup + + drain := func(name string, router *nbtcp.Router) { + wg.Add(1) + go func() { + defer wg.Done() + if ok := router.Drain(timeout); !ok { + s.Logger.Warnf("timed out draining %s relay connections", name) + } + }() + } + + if s.mainRouter != nil { + drain("main router", s.mainRouter) + } + + s.portMu.RLock() + for port, pr := range s.portRouters { + drain(fmt.Sprintf("port %d", port), pr.router) + } + s.portMu.RUnlock() + + wg.Wait() +} + +func (s *Server) shutdownServices() { + var wg sync.WaitGroup + + shutdownHTTP := func(name string, shutdown func(context.Context) error) { + wg.Add(1) + go func() { + defer wg.Done() + ctx, cancel := context.WithTimeout(context.Background(), shutdownServiceTimeout) + defer cancel() + if err := shutdown(ctx); err != nil { + s.Logger.Debugf("%s shutdown: %v", name, err) + } + }() + } + + if s.healthServer != nil { + shutdownHTTP("health probe", s.healthServer.Shutdown) + } + if s.debug != nil { + shutdownHTTP("debug endpoint", s.debug.Shutdown) + } + if s.http != nil { + shutdownHTTP("acme http", s.http.Shutdown) + } + + if s.netbird != nil { + wg.Add(1) + go func() { + defer wg.Done() + ctx, cancel := context.WithTimeout(context.Background(), shutdownDrainTimeout) + defer cancel() + if err := s.netbird.StopAll(ctx); err != nil { + s.Logger.Warnf("stop netbird clients: %v", err) + } + }() + } + + // Close all UDP relays and wait for their goroutines to exit. + s.udpMu.Lock() + for id, relay := range s.udpRelays { + relay.Close() + delete(s.udpRelays, id) + } + s.udpMu.Unlock() + s.udpRelayWg.Wait() + + // Close all per-port routers. + s.portMu.Lock() + for port, pr := range s.portRouters { + pr.cancel() + if err := pr.listener.Close(); err != nil { + s.Logger.Debugf("close listener on port %d: %v", port, err) + } + delete(s.portRouters, port) + } + maps.Clear(s.svcPorts) + maps.Clear(s.lastMappings) + s.portMu.Unlock() + + // Wait for per-port router serve goroutines to exit. + s.portRouterWg.Wait() + + wg.Wait() + + if s.accessLog != nil { + s.accessLog.Close() + } + + if s.geoRaw != nil { + if err := s.geoRaw.Close(); err != nil { + s.Logger.Debugf("close geolocation: %v", err) + } + } +} + +// resolveDialFunc returns a DialContextFunc that dials through the +// NetBird tunnel for the given account. +func (s *Server) resolveDialFunc(accountID types.AccountID) (types.DialContextFunc, error) { + client, ok := s.netbird.GetClient(accountID) + if !ok { + return nil, fmt.Errorf("no client for account %s", accountID) + } + return client.DialContext, nil +} + +// notifyError reports a resource error back to management so it can be +// surfaced to the user (e.g. port bind failure, dialer resolution error). +func (s *Server) notifyError(ctx context.Context, mapping *proto.ProxyMapping, err error) { + s.sendStatusUpdate(ctx, types.AccountID(mapping.GetAccountId()), types.ServiceID(mapping.GetId()), proto.ProxyStatus_PROXY_STATUS_ERROR, err) +} + +// sendStatusUpdate sends a status update for a service to management. +func (s *Server) sendStatusUpdate(ctx context.Context, accountID types.AccountID, serviceID types.ServiceID, st proto.ProxyStatus, err error) { + req := &proto.SendStatusUpdateRequest{ + ServiceId: string(serviceID), + AccountId: string(accountID), + Status: st, + } + if err != nil { + msg := err.Error() + req.ErrorMessage = &msg + } + if _, sendErr := s.mgmtClient.SendStatusUpdate(ctx, req); sendErr != nil { + s.Logger.Debugf("failed to send status update for %s: %v", serviceID, sendErr) + } +} + +// routerForPort returns the router that handles the given listen port. If port +// is 0 or matches the main listener port, the main router is returned. +// Otherwise a new per-port router is created and started. +func (s *Server) routerForPort(ctx context.Context, port uint16) (*nbtcp.Router, error) { + if port == 0 || port == s.mainPort { + return s.mainRouter, nil + } + return s.getOrCreatePortRouter(ctx, port) +} + +// routerForPortExisting returns the router for the given port without creating +// one. Returns the main router for port 0 / mainPort, or nil if no per-port +// router exists. +func (s *Server) routerForPortExisting(port uint16) *nbtcp.Router { + if port == 0 || port == s.mainPort { + return s.mainRouter + } + s.portMu.RLock() + pr := s.portRouters[port] + s.portMu.RUnlock() + if pr != nil { + return pr.router + } + return nil +} + +// getOrCreatePortRouter returns an existing per-port router or creates one +// with a new TCP listener and starts serving. +func (s *Server) getOrCreatePortRouter(ctx context.Context, port uint16) (*nbtcp.Router, error) { + s.portMu.Lock() + defer s.portMu.Unlock() + + if pr, ok := s.portRouters[port]; ok { + return pr.router, nil + } + + listenAddr := fmt.Sprintf(":%d", port) + ln, err := net.Listen("tcp", listenAddr) + if err != nil { + return nil, fmt.Errorf("listen TCP on %s: %w", listenAddr, err) + } + if s.ProxyProtocol { + ln = s.wrapProxyProtocol(ln) + } + + router := nbtcp.NewPortRouter(s.Logger, s.resolveDialFunc) + router.SetObserver(s.meter) + router.SetAccessLogger(s.accessLog) + portCtx, cancel := context.WithCancel(ctx) + + s.portRouters[port] = &portRouter{ + router: router, + listener: ln, + cancel: cancel, + } + + s.portRouterWg.Add(1) + go func() { + defer s.portRouterWg.Done() + if err := router.Serve(portCtx, ln); err != nil { + s.Logger.Debugf("port %d router stopped: %v", port, err) + } + }() + + s.Logger.Debugf("started per-port router on %s", listenAddr) + return router, nil +} + +// cleanupPortIfEmpty tears down a per-port router if it has no remaining +// routes or fallback. The main port is never cleaned up. Active relay +// connections are drained before the listener is closed. +func (s *Server) cleanupPortIfEmpty(port uint16) { + if port == 0 || port == s.mainPort { + return + } + + s.portMu.Lock() + pr, ok := s.portRouters[port] + if !ok || !pr.router.IsEmpty() { + s.portMu.Unlock() + return + } + + // Cancel and close the listener while holding the lock so that + // getOrCreatePortRouter sees the entry is gone before we drain. + pr.cancel() + if err := pr.listener.Close(); err != nil { + s.Logger.Debugf("close listener on port %d: %v", port, err) + } + delete(s.portRouters, port) + s.portMu.Unlock() + + // Drain active relay connections outside the lock. + if ok := pr.router.Drain(nbtcp.DefaultDrainTimeout); !ok { + s.Logger.Warnf("timed out draining relay connections on port %d", port) + } + s.Logger.Debugf("cleaned up empty per-port router on port %d", port) +} + +func (s *Server) newManagementMappingWorker(ctx context.Context, client proto.ProxyServiceClient) { + bo := &backoff.ExponentialBackOff{ + InitialInterval: 800 * time.Millisecond, + RandomizationFactor: 1, + Multiplier: 1.7, + MaxInterval: 10 * time.Second, + MaxElapsedTime: 0, // retry indefinitely until context is canceled + Stop: backoff.Stop, + Clock: backoff.SystemClock, + } + + initialSyncDone := false + + operation := func() error { + s.Logger.Debug("connecting to management mapping stream") + + if s.healthChecker != nil { + s.healthChecker.SetManagementConnected(false) + } + + mappingClient, err := client.GetMappingUpdate(ctx, &proto.GetMappingUpdateRequest{ + ProxyId: s.ID, + Version: s.Version, + StartedAt: timestamppb.New(s.startTime), + Address: s.ProxyURL, + Capabilities: &proto.ProxyCapabilities{ + SupportsCustomPorts: &s.SupportsCustomPorts, + }, + }) + if err != nil { + return fmt.Errorf("create mapping stream: %w", err) + } + + if s.healthChecker != nil { + s.healthChecker.SetManagementConnected(true) + } + s.Logger.Debug("management mapping stream established") + + // Stream established — reset backoff so the next failure retries quickly. + bo.Reset() + + streamErr := s.handleMappingStream(ctx, mappingClient, &initialSyncDone) + + if s.healthChecker != nil { + s.healthChecker.SetManagementConnected(false) + } + + if streamErr == nil { + return fmt.Errorf("stream closed by server") + } + + return fmt.Errorf("mapping stream: %w", streamErr) + } + + notify := func(err error, next time.Duration) { + s.Logger.Warnf("management connection failed, retrying in %s: %v", next.Truncate(time.Millisecond), err) + } + + if err := backoff.RetryNotify(operation, backoff.WithContext(bo, ctx), notify); err != nil { + s.Logger.WithError(err).Debug("management mapping worker exiting") + } +} + +func (s *Server) handleMappingStream(ctx context.Context, mappingClient proto.ProxyService_GetMappingUpdateClient, initialSyncDone *bool) error { + select { + case <-s.routerReady: + case <-ctx.Done(): + return ctx.Err() + } + + for { + // Check for context completion to gracefully shutdown. + select { + case <-ctx.Done(): + // Shutting down. + return ctx.Err() + default: + msg, err := mappingClient.Recv() + switch { + case errors.Is(err, io.EOF): + // Mapping connection gracefully terminated by server. + return nil + case err != nil: + // Something has gone horribly wrong, return and hope the parent retries the connection. + return fmt.Errorf("receive msg: %w", err) + } + s.Logger.Debug("Received mapping update, starting processing") + s.processMappings(ctx, msg.GetMapping()) + s.Logger.Debug("Processing mapping update completed") + + if !*initialSyncDone && msg.GetInitialSyncComplete() { + if s.healthChecker != nil { + s.healthChecker.SetInitialSyncComplete() + } + *initialSyncDone = true + s.Logger.Info("Initial mapping sync complete") + } + } + } +} + +func (s *Server) processMappings(ctx context.Context, mappings []*proto.ProxyMapping) { + for _, mapping := range mappings { + s.Logger.WithFields(log.Fields{ + "type": mapping.GetType(), + "domain": mapping.GetDomain(), + "mode": mapping.GetMode(), + "port": mapping.GetListenPort(), + "id": mapping.GetId(), + }).Debug("Processing mapping update") + switch mapping.GetType() { + case proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED: + if err := s.addMapping(ctx, mapping); err != nil { + s.Logger.WithFields(log.Fields{ + "service_id": mapping.GetId(), + "domain": mapping.GetDomain(), + "error": err, + }).Error("Error adding new mapping, ignoring this mapping and continuing processing") + s.notifyError(ctx, mapping, err) + } + case proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED: + if err := s.modifyMapping(ctx, mapping); err != nil { + s.Logger.WithFields(log.Fields{ + "service_id": mapping.GetId(), + "domain": mapping.GetDomain(), + "error": err, + }).Error("failed to modify mapping") + s.notifyError(ctx, mapping, err) + } + case proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED: + s.removeMapping(ctx, mapping) + } + } +} + +// addMapping registers a service mapping and starts the appropriate relay or routes. +func (s *Server) addMapping(ctx context.Context, mapping *proto.ProxyMapping) error { + accountID := types.AccountID(mapping.GetAccountId()) + svcID := types.ServiceID(mapping.GetId()) + authToken := mapping.GetAuthToken() + + svcKey := s.serviceKeyForMapping(mapping) + if err := s.netbird.AddPeer(ctx, accountID, svcKey, authToken, svcID); err != nil { + return fmt.Errorf("create peer for service %s: %w", svcID, err) + } + + if err := s.setupMappingRoutes(ctx, mapping); err != nil { + s.cleanupMappingRoutes(mapping) + if peerErr := s.netbird.RemovePeer(ctx, accountID, svcKey); peerErr != nil { + s.Logger.WithError(peerErr).WithField("service_id", svcID).Warn("failed to remove peer after setup failure") + } + return err + } + s.storeMapping(mapping) + return nil +} + +// modifyMapping updates a service mapping in place without tearing down the +// NetBird peer. It cleans up old routes using the previously stored mapping +// state and re-applies them from the new mapping. +func (s *Server) modifyMapping(ctx context.Context, mapping *proto.ProxyMapping) error { + if old := s.loadMapping(types.ServiceID(mapping.GetId())); old != nil { + s.cleanupMappingRoutes(old) + if mode := types.ServiceMode(old.GetMode()); mode.IsL4() { + s.meter.L4ServiceRemoved(mode) + } + } else { + s.cleanupMappingRoutes(mapping) + } + if err := s.setupMappingRoutes(ctx, mapping); err != nil { + s.cleanupMappingRoutes(mapping) + return err + } + s.storeMapping(mapping) + return nil +} + +// setupMappingRoutes configures the appropriate routes or relays for the given +// service mapping based on its mode. The NetBird peer must already exist. +func (s *Server) setupMappingRoutes(ctx context.Context, mapping *proto.ProxyMapping) error { + switch types.ServiceMode(mapping.GetMode()) { + case types.ServiceModeTCP: + return s.setupTCPMapping(ctx, mapping) + case types.ServiceModeUDP: + return s.setupUDPMapping(ctx, mapping) + case types.ServiceModeTLS: + return s.setupTLSMapping(ctx, mapping) + default: + return s.setupHTTPMapping(ctx, mapping) + } +} + +// setupHTTPMapping configures HTTP reverse proxy, auth, and ACME routes. +func (s *Server) setupHTTPMapping(ctx context.Context, mapping *proto.ProxyMapping) error { + d := domain.Domain(mapping.GetDomain()) + accountID := types.AccountID(mapping.GetAccountId()) + svcID := types.ServiceID(mapping.GetId()) + + if len(mapping.GetPath()) == 0 { + return nil + } + + var wildcardHit bool + if s.acme != nil { + wildcardHit = s.acme.AddDomain(d, accountID, svcID) + } + s.mainRouter.AddRoute(nbtcp.SNIHost(mapping.GetDomain()), nbtcp.Route{ + Type: nbtcp.RouteHTTP, + AccountID: accountID, + ServiceID: svcID, + Domain: mapping.GetDomain(), + }) + if err := s.updateMapping(ctx, mapping); err != nil { + return fmt.Errorf("update mapping for domain %q: %w", d, err) + } + + if wildcardHit { + if err := s.NotifyCertificateIssued(ctx, accountID, svcID, string(d)); err != nil { + s.Logger.Warnf("notify certificate ready for domain %q: %v", d, err) + } + } + + return nil +} + +// setupTCPMapping sets up a TCP port-forwarding fallback route on the listen port. +func (s *Server) setupTCPMapping(ctx context.Context, mapping *proto.ProxyMapping) error { + svcID := types.ServiceID(mapping.GetId()) + accountID := types.AccountID(mapping.GetAccountId()) + + port, err := netutil.ValidatePort(mapping.GetListenPort()) + if err != nil { + return fmt.Errorf("TCP service %s: %w", svcID, err) + } + + targetAddr := s.l4TargetAddress(mapping) + if targetAddr == "" { + return fmt.Errorf("empty target address for TCP service %s", svcID) + } + + if s.WireguardPort != 0 && port == s.WireguardPort { + return fmt.Errorf("port %d conflicts with tunnel port", port) + } + + router, err := s.routerForPort(ctx, port) + if err != nil { + return fmt.Errorf("router for TCP port %d: %w", port, err) + } + + s.warnIfGeoUnavailable(mapping.GetDomain(), mapping.GetAccessRestrictions()) + + router.SetGeo(s.geo) + router.SetFallback(nbtcp.Route{ + Type: nbtcp.RouteTCP, + AccountID: accountID, + ServiceID: svcID, + Domain: mapping.GetDomain(), + Protocol: accesslog.ProtocolTCP, + Target: targetAddr, + ProxyProtocol: s.l4ProxyProtocol(mapping), + DialTimeout: s.l4DialTimeout(mapping), + SessionIdleTimeout: s.clampIdleTimeout(l4SessionIdleTimeout(mapping)), + Filter: parseRestrictions(mapping), + }) + + s.portMu.Lock() + s.svcPorts[svcID] = []uint16{port} + s.portMu.Unlock() + + s.meter.L4ServiceAdded(types.ServiceModeTCP) + s.sendStatusUpdate(ctx, accountID, svcID, proto.ProxyStatus_PROXY_STATUS_ACTIVE, nil) + return nil +} + +// setupUDPMapping starts a UDP relay on the listen port. +func (s *Server) setupUDPMapping(ctx context.Context, mapping *proto.ProxyMapping) error { + svcID := types.ServiceID(mapping.GetId()) + accountID := types.AccountID(mapping.GetAccountId()) + + port, err := netutil.ValidatePort(mapping.GetListenPort()) + if err != nil { + return fmt.Errorf("UDP service %s: %w", svcID, err) + } + + targetAddr := s.l4TargetAddress(mapping) + if targetAddr == "" { + return fmt.Errorf("empty target address for UDP service %s", svcID) + } + + s.warnIfGeoUnavailable(mapping.GetDomain(), mapping.GetAccessRestrictions()) + + if err := s.addUDPRelay(ctx, mapping, targetAddr, port); err != nil { + return fmt.Errorf("UDP relay for service %s: %w", svcID, err) + } + + s.meter.L4ServiceAdded(types.ServiceModeUDP) + s.sendStatusUpdate(ctx, accountID, svcID, proto.ProxyStatus_PROXY_STATUS_ACTIVE, nil) + return nil +} + +// setupTLSMapping configures a TLS SNI-routed passthrough on the listen port. +func (s *Server) setupTLSMapping(ctx context.Context, mapping *proto.ProxyMapping) error { + svcID := types.ServiceID(mapping.GetId()) + accountID := types.AccountID(mapping.GetAccountId()) + + tlsPort, err := netutil.ValidatePort(mapping.GetListenPort()) + if err != nil { + return fmt.Errorf("TLS service %s: %w", svcID, err) + } + + targetAddr := s.l4TargetAddress(mapping) + if targetAddr == "" { + return fmt.Errorf("empty target address for TLS service %s", svcID) + } + + if s.WireguardPort != 0 && tlsPort == s.WireguardPort { + return fmt.Errorf("port %d conflicts with tunnel port", tlsPort) + } + + router, err := s.routerForPort(ctx, tlsPort) + if err != nil { + return fmt.Errorf("router for TLS port %d: %w", tlsPort, err) + } + + s.warnIfGeoUnavailable(mapping.GetDomain(), mapping.GetAccessRestrictions()) + + router.SetGeo(s.geo) + router.AddRoute(nbtcp.SNIHost(mapping.GetDomain()), nbtcp.Route{ + Type: nbtcp.RouteTCP, + AccountID: accountID, + ServiceID: svcID, + Domain: mapping.GetDomain(), + Protocol: accesslog.ProtocolTLS, + Target: targetAddr, + ProxyProtocol: s.l4ProxyProtocol(mapping), + DialTimeout: s.l4DialTimeout(mapping), + SessionIdleTimeout: s.clampIdleTimeout(l4SessionIdleTimeout(mapping)), + Filter: parseRestrictions(mapping), + }) + + if tlsPort != s.mainPort { + s.portMu.Lock() + s.svcPorts[svcID] = []uint16{tlsPort} + s.portMu.Unlock() + } + + s.Logger.WithFields(log.Fields{ + "domain": mapping.GetDomain(), + "target": targetAddr, + "port": tlsPort, + "service": svcID, + }).Info("TLS passthrough mapping added") + + s.meter.L4ServiceAdded(types.ServiceModeTLS) + s.sendStatusUpdate(ctx, accountID, svcID, proto.ProxyStatus_PROXY_STATUS_ACTIVE, nil) + return nil +} + +// serviceKeyForMapping returns the appropriate ServiceKey for a mapping. +// TCP/UDP use an ID-based key; HTTP/TLS use a domain-based key. +func (s *Server) serviceKeyForMapping(mapping *proto.ProxyMapping) roundtrip.ServiceKey { + switch types.ServiceMode(mapping.GetMode()) { + case types.ServiceModeTCP, types.ServiceModeUDP: + return roundtrip.L4ServiceKey(types.ServiceID(mapping.GetId())) + default: + return roundtrip.DomainServiceKey(mapping.GetDomain()) + } +} + +// parseRestrictions converts a proto mapping's access restrictions into +// a restrict.Filter. Returns nil if the mapping has no restrictions. +func parseRestrictions(mapping *proto.ProxyMapping) *restrict.Filter { + r := mapping.GetAccessRestrictions() + if r == nil { + return nil + } + return restrict.ParseFilter(r.GetAllowedCidrs(), r.GetBlockedCidrs(), r.GetAllowedCountries(), r.GetBlockedCountries()) +} + +// warnIfGeoUnavailable logs a warning if the mapping has country restrictions +// but the proxy has no geolocation database loaded. All requests to this +// service will be denied at runtime (fail-close). +func (s *Server) warnIfGeoUnavailable(domain string, r *proto.AccessRestrictions) { + if r == nil { + return + } + if len(r.GetAllowedCountries()) == 0 && len(r.GetBlockedCountries()) == 0 { + return + } + if s.geo != nil && s.geo.Available() { + return + } + s.Logger.Warnf("service %s has country restrictions but no geolocation database is loaded: all requests will be denied", domain) +} + +// l4TargetAddress extracts and validates the target address from a mapping's +// first path entry. Returns empty string if no paths exist or the address is +// not a valid host:port. +func (s *Server) l4TargetAddress(mapping *proto.ProxyMapping) string { + paths := mapping.GetPath() + if len(paths) == 0 { + return "" + } + target := paths[0].GetTarget() + if _, _, err := net.SplitHostPort(target); err != nil { + s.Logger.WithFields(log.Fields{ + "service_id": mapping.GetId(), + "target": target, + }).Warnf("invalid L4 target address: %v", err) + return "" + } + return target +} + +// l4ProxyProtocol returns whether the first target has PROXY protocol enabled. +func (s *Server) l4ProxyProtocol(mapping *proto.ProxyMapping) bool { + paths := mapping.GetPath() + if len(paths) == 0 { + return false + } + return paths[0].GetOptions().GetProxyProtocol() +} + +// l4DialTimeout returns the dial timeout from the first target's options, +// clamped to MaxDialTimeout. +func (s *Server) l4DialTimeout(mapping *proto.ProxyMapping) time.Duration { + paths := mapping.GetPath() + if len(paths) > 0 { + if d := paths[0].GetOptions().GetRequestTimeout(); d != nil { + return s.clampDialTimeout(d.AsDuration()) + } + } + return s.clampDialTimeout(0) +} + +// l4SessionIdleTimeout returns the configured session idle timeout from the +// mapping options, or 0 to use the relay's default. +func l4SessionIdleTimeout(mapping *proto.ProxyMapping) time.Duration { + paths := mapping.GetPath() + if len(paths) > 0 { + if d := paths[0].GetOptions().GetSessionIdleTimeout(); d != nil { + return d.AsDuration() + } + } + return 0 +} + +// addUDPRelay starts a UDP relay on the specified listen port. +func (s *Server) addUDPRelay(ctx context.Context, mapping *proto.ProxyMapping, targetAddress string, listenPort uint16) error { + svcID := types.ServiceID(mapping.GetId()) + accountID := types.AccountID(mapping.GetAccountId()) + + if s.WireguardPort != 0 && listenPort == s.WireguardPort { + return fmt.Errorf("UDP port %d conflicts with tunnel port", listenPort) + } + + // Close existing relay if present (idempotent re-add). + s.removeUDPRelay(svcID) + + listenAddr := fmt.Sprintf(":%d", listenPort) + + listener, err := net.ListenPacket("udp", listenAddr) + if err != nil { + return fmt.Errorf("listen UDP on %s: %w", listenAddr, err) + } + + dialFn, err := s.resolveDialFunc(accountID) + if err != nil { + if err := listener.Close(); err != nil { + s.Logger.Debugf("close UDP listener on %s: %v", listenAddr, err) + } + return fmt.Errorf("resolve dialer for UDP: %w", err) + } + + entry := s.Logger.WithFields(log.Fields{ + "target": targetAddress, + "listen_port": listenPort, + "service_id": svcID, + }) + + relay := udprelay.New(ctx, udprelay.RelayConfig{ + Logger: entry, + Listener: listener, + Target: targetAddress, + Domain: mapping.GetDomain(), + AccountID: accountID, + ServiceID: svcID, + DialFunc: dialFn, + DialTimeout: s.l4DialTimeout(mapping), + SessionTTL: s.clampIdleTimeout(l4SessionIdleTimeout(mapping)), + AccessLog: s.accessLog, + Filter: parseRestrictions(mapping), + Geo: s.geo, + }) + relay.SetObserver(s.meter) + + s.udpMu.Lock() + s.udpRelays[svcID] = relay + s.udpMu.Unlock() + + s.udpRelayWg.Go(relay.Serve) + entry.Info("UDP relay added") + return nil +} + +func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping) error { + // Very simple implementation here, we don't touch the existing peer + // connection or any existing TLS configuration, we simply overwrite + // the auth and proxy mappings. + // Note: this does require the management server to always send a + // full mapping rather than deltas during a modification. + accountID := types.AccountID(mapping.GetAccountId()) + svcID := types.ServiceID(mapping.GetId()) + + var schemes []auth.Scheme + if mapping.GetAuth().GetPassword() { + schemes = append(schemes, auth.NewPassword(s.mgmtClient, svcID, accountID)) + } + if mapping.GetAuth().GetPin() { + schemes = append(schemes, auth.NewPin(s.mgmtClient, svcID, accountID)) + } + if mapping.GetAuth().GetOidc() { + schemes = append(schemes, auth.NewOIDC(s.mgmtClient, svcID, accountID, s.ForwardedProto)) + } + for _, ha := range mapping.GetAuth().GetHeaderAuths() { + schemes = append(schemes, auth.NewHeader(s.mgmtClient, svcID, accountID, ha.GetHeader())) + } + + ipRestrictions := parseRestrictions(mapping) + s.warnIfGeoUnavailable(mapping.GetDomain(), mapping.GetAccessRestrictions()) + + maxSessionAge := time.Duration(mapping.GetAuth().GetMaxSessionAgeSeconds()) * time.Second + if err := s.auth.AddDomain(mapping.GetDomain(), schemes, mapping.GetAuth().GetSessionKey(), maxSessionAge, accountID, svcID, ipRestrictions); err != nil { + return fmt.Errorf("auth setup for domain %s: %w", mapping.GetDomain(), err) + } + m := s.protoToMapping(ctx, mapping) + s.proxy.AddMapping(m) + s.meter.AddMapping(m) + return nil +} + +// removeMapping tears down routes/relays and the NetBird peer for a service. +// Uses the stored mapping state when available to ensure all previously +// configured routes are cleaned up. +func (s *Server) removeMapping(ctx context.Context, mapping *proto.ProxyMapping) { + accountID := types.AccountID(mapping.GetAccountId()) + svcKey := s.serviceKeyForMapping(mapping) + if err := s.netbird.RemovePeer(ctx, accountID, svcKey); err != nil { + s.Logger.WithFields(log.Fields{ + "account_id": accountID, + "service_id": mapping.GetId(), + "error": err, + }).Error("failed to remove NetBird peer, continuing cleanup") + } + + if old := s.deleteMapping(types.ServiceID(mapping.GetId())); old != nil { + s.cleanupMappingRoutes(old) + if mode := types.ServiceMode(old.GetMode()); mode.IsL4() { + s.meter.L4ServiceRemoved(mode) + } + } else { + s.cleanupMappingRoutes(mapping) + } +} + +// cleanupMappingRoutes removes HTTP/TLS/L4 routes and custom port state for a +// service without touching the NetBird peer. This is used for both full +// removal and in-place modification of mappings. +func (s *Server) cleanupMappingRoutes(mapping *proto.ProxyMapping) { + svcID := types.ServiceID(mapping.GetId()) + host := mapping.GetDomain() + + // HTTP/TLS cleanup (only relevant when a domain is set). + if host != "" { + d := domain.Domain(host) + if s.acme != nil { + s.acme.RemoveDomain(d) + } + s.auth.RemoveDomain(host) + if s.proxy.RemoveMapping(proxy.Mapping{Host: host}) { + s.meter.RemoveMapping(proxy.Mapping{Host: host}) + } + // Close hijacked connections (WebSocket) for this domain. + if n := s.hijackTracker.CloseByHost(host); n > 0 { + s.Logger.Debugf("closed %d hijacked connection(s) for %s", n, host) + } + // Remove SNI route from the main router (covers both HTTP and main-port TLS). + s.mainRouter.RemoveRoute(nbtcp.SNIHost(host), svcID) + } + + // Extract and delete tracked custom-port entries atomically. + s.portMu.Lock() + entries := s.svcPorts[svcID] + delete(s.svcPorts, svcID) + s.portMu.Unlock() + + for _, entry := range entries { + if router := s.routerForPortExisting(entry); router != nil { + if host != "" { + router.RemoveRoute(nbtcp.SNIHost(host), svcID) + } else { + router.RemoveFallback(svcID) + } + } + s.cleanupPortIfEmpty(entry) + } + + // UDP relay cleanup (idempotent). + s.removeUDPRelay(svcID) + +} + +// removeUDPRelay stops and removes a UDP relay by service ID. +func (s *Server) removeUDPRelay(svcID types.ServiceID) { + s.udpMu.Lock() + relay, ok := s.udpRelays[svcID] + if ok { + delete(s.udpRelays, svcID) + } + s.udpMu.Unlock() + + if ok { + relay.Close() + s.Logger.WithField("service_id", svcID).Info("UDP relay removed") + } +} + +func (s *Server) storeMapping(mapping *proto.ProxyMapping) { + s.portMu.Lock() + s.lastMappings[types.ServiceID(mapping.GetId())] = mapping + s.portMu.Unlock() +} + +func (s *Server) loadMapping(svcID types.ServiceID) *proto.ProxyMapping { + s.portMu.RLock() + m := s.lastMappings[svcID] + s.portMu.RUnlock() + return m +} + +func (s *Server) deleteMapping(svcID types.ServiceID) *proto.ProxyMapping { + s.portMu.Lock() + m := s.lastMappings[svcID] + delete(s.lastMappings, svcID) + s.portMu.Unlock() + return m +} + +func (s *Server) protoToMapping(ctx context.Context, mapping *proto.ProxyMapping) proxy.Mapping { + paths := make(map[string]*proxy.PathTarget) + for _, pathMapping := range mapping.GetPath() { + targetURL, err := url.Parse(pathMapping.GetTarget()) + if err != nil { + s.Logger.WithFields(log.Fields{ + "service_id": mapping.GetId(), + "account_id": mapping.GetAccountId(), + "domain": mapping.GetDomain(), + "path": pathMapping.GetPath(), + "target": pathMapping.GetTarget(), + }).WithError(err).Error("failed to parse target URL for path, skipping") + s.notifyError(ctx, mapping, fmt.Errorf("invalid target URL %q for path %q: %w", pathMapping.GetTarget(), pathMapping.GetPath(), err)) + continue + } + + pt := &proxy.PathTarget{URL: targetURL} + if opts := pathMapping.GetOptions(); opts != nil { + pt.SkipTLSVerify = opts.GetSkipTlsVerify() + pt.PathRewrite = protoToPathRewrite(opts.GetPathRewrite()) + pt.CustomHeaders = opts.GetCustomHeaders() + if d := opts.GetRequestTimeout(); d != nil { + pt.RequestTimeout = d.AsDuration() + } + } + pt.RequestTimeout = s.clampDialTimeout(pt.RequestTimeout) + paths[pathMapping.GetPath()] = pt + } + m := proxy.Mapping{ + ID: types.ServiceID(mapping.GetId()), + AccountID: types.AccountID(mapping.GetAccountId()), + Host: mapping.GetDomain(), + Paths: paths, + PassHostHeader: mapping.GetPassHostHeader(), + RewriteRedirects: mapping.GetRewriteRedirects(), + } + for _, ha := range mapping.GetAuth().GetHeaderAuths() { + m.StripAuthHeaders = append(m.StripAuthHeaders, ha.GetHeader()) + } + return m +} + +func protoToPathRewrite(mode proto.PathRewriteMode) proxy.PathRewriteMode { + switch mode { + case proto.PathRewriteMode_PATH_REWRITE_PRESERVE: + return proxy.PathRewritePreserve + default: + return proxy.PathRewriteDefault + } +} + +// debugEndpointAddr returns the address for the debug endpoint. +// If addr is empty, it defaults to localhost:8444 for security. +func debugEndpointAddr(addr string) string { + if addr == "" { + return defaultDebugAddr + } + return addr +} diff --git a/proxy/server_test.go b/proxy/server_test.go new file mode 100644 index 000000000..b4fb4f8ba --- /dev/null +++ b/proxy/server_test.go @@ -0,0 +1,48 @@ +package proxy + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDebugEndpointDisabledByDefault(t *testing.T) { + s := &Server{} + assert.False(t, s.DebugEndpointEnabled, "debug endpoint should be disabled by default") +} + +func TestDebugEndpointAddr(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "empty defaults to localhost", + input: "", + expected: "localhost:8444", + }, + { + name: "explicit localhost preserved", + input: "localhost:9999", + expected: "localhost:9999", + }, + { + name: "explicit address preserved", + input: "0.0.0.0:8444", + expected: "0.0.0.0:8444", + }, + { + name: "127.0.0.1 preserved", + input: "127.0.0.1:8444", + expected: "127.0.0.1:8444", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := debugEndpointAddr(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} diff --git a/proxy/trustedproxy.go b/proxy/trustedproxy.go new file mode 100644 index 000000000..3a1f0ad37 --- /dev/null +++ b/proxy/trustedproxy.go @@ -0,0 +1,43 @@ +package proxy + +import ( + "fmt" + "net/netip" + "strings" +) + +// ParseTrustedProxies parses a comma-separated list of CIDR prefixes or bare IPs +// into a slice of netip.Prefix values suitable for trusted proxy configuration. +// Bare IPs are converted to single-host prefixes (/32 or /128). +func ParseTrustedProxies(raw string) ([]netip.Prefix, error) { + if raw == "" { + return nil, nil + } + + parts := strings.Split(raw, ",") + prefixes := make([]netip.Prefix, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + prefix, err := netip.ParsePrefix(part) + if err == nil { + prefixes = append(prefixes, prefix) + continue + } + + addr, addrErr := netip.ParseAddr(part) + if addrErr != nil { + return nil, fmt.Errorf("parse trusted proxy %q: not a valid CIDR or IP: %w", part, addrErr) + } + + bits := 32 + if addr.Is6() { + bits = 128 + } + prefixes = append(prefixes, netip.PrefixFrom(addr, bits)) + } + return prefixes, nil +} diff --git a/proxy/trustedproxy_test.go b/proxy/trustedproxy_test.go new file mode 100644 index 000000000..974e56863 --- /dev/null +++ b/proxy/trustedproxy_test.go @@ -0,0 +1,90 @@ +package proxy + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseTrustedProxies(t *testing.T) { + tests := []struct { + name string + raw string + want []netip.Prefix + wantErr bool + }{ + { + name: "empty string returns nil", + raw: "", + want: nil, + }, + { + name: "single CIDR", + raw: "10.0.0.0/8", + want: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}, + }, + { + name: "single bare IPv4", + raw: "1.2.3.4", + want: []netip.Prefix{netip.MustParsePrefix("1.2.3.4/32")}, + }, + { + name: "single bare IPv6", + raw: "::1", + want: []netip.Prefix{netip.MustParsePrefix("::1/128")}, + }, + { + name: "comma-separated CIDRs", + raw: "10.0.0.0/8, 192.168.1.0/24", + want: []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("192.168.1.0/24"), + }, + }, + { + name: "mixed CIDRs and bare IPs", + raw: "10.0.0.0/8, 1.2.3.4, fd00::/8", + want: []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("1.2.3.4/32"), + netip.MustParsePrefix("fd00::/8"), + }, + }, + { + name: "whitespace around entries", + raw: " 10.0.0.0/8 , 192.168.0.0/16 ", + want: []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("192.168.0.0/16"), + }, + }, + { + name: "trailing comma produces no extra entry", + raw: "10.0.0.0/8,", + want: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}, + }, + { + name: "invalid entry", + raw: "not-an-ip", + wantErr: true, + }, + { + name: "partially invalid", + raw: "10.0.0.0/8, garbage", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseTrustedProxies(tt.raw) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/proxy/web/.gitignore b/proxy/web/.gitignore new file mode 100644 index 000000000..251ce6d2b --- /dev/null +++ b/proxy/web/.gitignore @@ -0,0 +1,23 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/proxy/web/dist/assets/Inter-Italic-VariableFont_opsz_wght.ttf b/proxy/web/dist/assets/Inter-Italic-VariableFont_opsz_wght.ttf new file mode 100644 index 000000000..43ed4f5ee Binary files /dev/null and b/proxy/web/dist/assets/Inter-Italic-VariableFont_opsz_wght.ttf differ diff --git a/proxy/web/dist/assets/Inter-VariableFont_opsz_wght.ttf b/proxy/web/dist/assets/Inter-VariableFont_opsz_wght.ttf new file mode 100644 index 000000000..e31b51e3e Binary files /dev/null and b/proxy/web/dist/assets/Inter-VariableFont_opsz_wght.ttf differ diff --git a/proxy/web/dist/assets/favicon.ico b/proxy/web/dist/assets/favicon.ico new file mode 100644 index 000000000..50bb80966 Binary files /dev/null and b/proxy/web/dist/assets/favicon.ico differ diff --git a/proxy/web/dist/assets/index.js b/proxy/web/dist/assets/index.js new file mode 100644 index 000000000..9ce3e4394 --- /dev/null +++ b/proxy/web/dist/assets/index.js @@ -0,0 +1,9 @@ +(function(){const v=document.createElement("link").relList;if(v&&v.supports&&v.supports("modulepreload"))return;for(const _ of document.querySelectorAll('link[rel="modulepreload"]'))f(_);new MutationObserver(_=>{for(const O of _)if(O.type==="childList")for(const D of O.addedNodes)D.tagName==="LINK"&&D.rel==="modulepreload"&&f(D)}).observe(document,{childList:!0,subtree:!0});function S(_){const O={};return _.integrity&&(O.integrity=_.integrity),_.referrerPolicy&&(O.referrerPolicy=_.referrerPolicy),_.crossOrigin==="use-credentials"?O.credentials="include":_.crossOrigin==="anonymous"?O.credentials="omit":O.credentials="same-origin",O}function f(_){if(_.ep)return;_.ep=!0;const O=S(_);fetch(_.href,O)}})();var Sf={exports:{}},Du={};var Yd;function jm(){if(Yd)return Du;Yd=1;var r=Symbol.for("react.transitional.element"),v=Symbol.for("react.fragment");function S(f,_,O){var D=null;if(O!==void 0&&(D=""+O),_.key!==void 0&&(D=""+_.key),"key"in _){O={};for(var U in _)U!=="key"&&(O[U]=_[U])}else O=_;return _=O.ref,{$$typeof:r,type:f,key:D,ref:_!==void 0?_:null,props:O}}return Du.Fragment=v,Du.jsx=S,Du.jsxs=S,Du}var Gd;function Rm(){return Gd||(Gd=1,Sf.exports=jm()),Sf.exports}var A=Rm(),xf={exports:{}},K={};var Xd;function Hm(){if(Xd)return K;Xd=1;var r=Symbol.for("react.transitional.element"),v=Symbol.for("react.portal"),S=Symbol.for("react.fragment"),f=Symbol.for("react.strict_mode"),_=Symbol.for("react.profiler"),O=Symbol.for("react.consumer"),D=Symbol.for("react.context"),U=Symbol.for("react.forward_ref"),N=Symbol.for("react.suspense"),p=Symbol.for("react.memo"),R=Symbol.for("react.lazy"),H=Symbol.for("react.activity"),V=Symbol.iterator;function st(s){return s===null||typeof s!="object"?null:(s=V&&s[V]||s["@@iterator"],typeof s=="function"?s:null)}var ct={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},G=Object.assign,Q={};function L(s,M,j){this.props=s,this.context=M,this.refs=Q,this.updater=j||ct}L.prototype.isReactComponent={},L.prototype.setState=function(s,M){if(typeof s!="object"&&typeof s!="function"&&s!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,s,M,"setState")},L.prototype.forceUpdate=function(s){this.updater.enqueueForceUpdate(this,s,"forceUpdate")};function gt(){}gt.prototype=L.prototype;function zt(s,M,j){this.props=s,this.context=M,this.refs=Q,this.updater=j||ct}var _t=zt.prototype=new gt;_t.constructor=zt,G(_t,L.prototype),_t.isPureReactComponent=!0;var it=Array.isArray;function Ot(){}var J={H:null,A:null,T:null,S:null},Rt=Object.prototype.hasOwnProperty;function It(s,M,j){var q=j.ref;return{$$typeof:r,type:s,key:M,ref:q!==void 0?q:null,props:j}}function jl(s,M){return It(s.type,M,s.props)}function Pt(s){return typeof s=="object"&&s!==null&&s.$$typeof===r}function I(s){var M={"=":"=0",":":"=2"};return"$"+s.replace(/[=:]/g,function(j){return M[j]})}var Rl=/\/+/g;function tl(s,M){return typeof s=="object"&&s!==null&&s.key!=null?I(""+s.key):M.toString(36)}function ll(s){switch(s.status){case"fulfilled":return s.value;case"rejected":throw s.reason;default:switch(typeof s.status=="string"?s.then(Ot,Ot):(s.status="pending",s.then(function(M){s.status==="pending"&&(s.status="fulfilled",s.value=M)},function(M){s.status==="pending"&&(s.status="rejected",s.reason=M)})),s.status){case"fulfilled":return s.value;case"rejected":throw s.reason}}throw s}function x(s,M,j,q,k){var P=typeof s;(P==="undefined"||P==="boolean")&&(s=null);var yt=!1;if(s===null)yt=!0;else switch(P){case"bigint":case"string":case"number":yt=!0;break;case"object":switch(s.$$typeof){case r:case v:yt=!0;break;case R:return yt=s._init,x(yt(s._payload),M,j,q,k)}}if(yt)return k=k(s),yt=q===""?"."+tl(s,0):q,it(k)?(j="",yt!=null&&(j=yt.replace(Rl,"$&/")+"/"),x(k,M,j,"",function(qa){return qa})):k!=null&&(Pt(k)&&(k=jl(k,j+(k.key==null||s&&s.key===k.key?"":(""+k.key).replace(Rl,"$&/")+"/")+yt)),M.push(k)),1;yt=0;var Wt=q===""?".":q+":";if(it(s))for(var Ut=0;Ut>>1,dt=x[nt];if(0<_(dt,C))x[nt]=C,x[Z]=dt,Z=nt;else break t}}function S(x){return x.length===0?null:x[0]}function f(x){if(x.length===0)return null;var C=x[0],Z=x.pop();if(Z!==C){x[0]=Z;t:for(var nt=0,dt=x.length,s=dt>>>1;nt_(j,Z))q_(k,j)?(x[nt]=k,x[q]=Z,nt=q):(x[nt]=j,x[M]=Z,nt=M);else if(q_(k,Z))x[nt]=k,x[q]=Z,nt=q;else break t}}return C}function _(x,C){var Z=x.sortIndex-C.sortIndex;return Z!==0?Z:x.id-C.id}if(r.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var O=performance;r.unstable_now=function(){return O.now()}}else{var D=Date,U=D.now();r.unstable_now=function(){return D.now()-U}}var N=[],p=[],R=1,H=null,V=3,st=!1,ct=!1,G=!1,Q=!1,L=typeof setTimeout=="function"?setTimeout:null,gt=typeof clearTimeout=="function"?clearTimeout:null,zt=typeof setImmediate<"u"?setImmediate:null;function _t(x){for(var C=S(p);C!==null;){if(C.callback===null)f(p);else if(C.startTime<=x)f(p),C.sortIndex=C.expirationTime,v(N,C);else break;C=S(p)}}function it(x){if(G=!1,_t(x),!ct)if(S(N)!==null)ct=!0,Ot||(Ot=!0,I());else{var C=S(p);C!==null&&ll(it,C.startTime-x)}}var Ot=!1,J=-1,Rt=5,It=-1;function jl(){return Q?!0:!(r.unstable_now()-Itx&&jl());){var nt=H.callback;if(typeof nt=="function"){H.callback=null,V=H.priorityLevel;var dt=nt(H.expirationTime<=x);if(x=r.unstable_now(),typeof dt=="function"){H.callback=dt,_t(x),C=!0;break l}H===S(N)&&f(N),_t(x)}else f(N);H=S(N)}if(H!==null)C=!0;else{var s=S(p);s!==null&&ll(it,s.startTime-x),C=!1}}break t}finally{H=null,V=Z,st=!1}C=void 0}}finally{C?I():Ot=!1}}}var I;if(typeof zt=="function")I=function(){zt(Pt)};else if(typeof MessageChannel<"u"){var Rl=new MessageChannel,tl=Rl.port2;Rl.port1.onmessage=Pt,I=function(){tl.postMessage(null)}}else I=function(){L(Pt,0)};function ll(x,C){J=L(function(){x(r.unstable_now())},C)}r.unstable_IdlePriority=5,r.unstable_ImmediatePriority=1,r.unstable_LowPriority=4,r.unstable_NormalPriority=3,r.unstable_Profiling=null,r.unstable_UserBlockingPriority=2,r.unstable_cancelCallback=function(x){x.callback=null},r.unstable_forceFrameRate=function(x){0>x||125nt?(x.sortIndex=Z,v(p,x),S(N)===null&&x===S(p)&&(G?(gt(J),J=-1):G=!0,ll(it,Z-nt))):(x.sortIndex=dt,v(N,x),ct||st||(ct=!0,Ot||(Ot=!0,I()))),x},r.unstable_shouldYield=jl,r.unstable_wrapCallback=function(x){var C=V;return function(){var Z=V;V=C;try{return x.apply(this,arguments)}finally{V=Z}}}})(Ef)),Ef}var wd;function qm(){return wd||(wd=1,Tf.exports=Bm()),Tf.exports}var Af={exports:{}},kt={};var Ld;function Ym(){if(Ld)return kt;Ld=1;var r=Rf();function v(N){var p="https://react.dev/errors/"+N;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(r)}catch(v){console.error(v)}}return r(),Af.exports=Ym(),Af.exports}var Kd;function Xm(){if(Kd)return Uu;Kd=1;var r=qm(),v=Rf(),S=Gm();function f(t){var l="https://react.dev/errors/"+t;if(1dt||(t.current=nt[dt],nt[dt]=null,dt--)}function j(t,l){dt++,nt[dt]=t.current,t.current=l}var q=s(null),k=s(null),P=s(null),yt=s(null);function Wt(t,l){switch(j(P,l),j(k,t),j(q,null),l.nodeType){case 9:case 11:t=(t=l.documentElement)&&(t=t.namespaceURI)?cd(t):0;break;default:if(t=l.tagName,l=l.namespaceURI)l=cd(l),t=fd(l,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}M(q),j(q,t)}function Ut(){M(q),M(k),M(P)}function qa(t){t.memoizedState!==null&&j(yt,t);var l=q.current,e=fd(l,t.type);l!==e&&(j(k,t),j(q,e))}function Hu(t){k.current===t&&(M(q),M(k)),yt.current===t&&(M(yt),Mu._currentValue=Z)}var li,Bf;function Ue(t){if(li===void 0)try{throw Error()}catch(e){var l=e.stack.trim().match(/\n( *(at )?)/);li=l&&l[1]||"",Bf=-1)":-1u||o[a]!==h[u]){var z=` +`+o[a].replace(" at new "," at ");return t.displayName&&z.includes("")&&(z=z.replace("",t.displayName)),z}while(1<=a&&0<=u);break}}}finally{ei=!1,Error.prepareStackTrace=e}return(e=t?t.displayName||t.name:"")?Ue(e):""}function o0(t,l){switch(t.tag){case 26:case 27:case 5:return Ue(t.type);case 16:return Ue("Lazy");case 13:return t.child!==l&&l!==null?Ue("Suspense Fallback"):Ue("Suspense");case 19:return Ue("SuspenseList");case 0:case 15:return ai(t.type,!1);case 11:return ai(t.type.render,!1);case 1:return ai(t.type,!0);case 31:return Ue("Activity");default:return""}}function qf(t){try{var l="",e=null;do l+=o0(t,e),e=t,t=t.return;while(t);return l}catch(a){return` +Error generating stack: `+a.message+` +`+a.stack}}var ui=Object.prototype.hasOwnProperty,ni=r.unstable_scheduleCallback,ii=r.unstable_cancelCallback,s0=r.unstable_shouldYield,d0=r.unstable_requestPaint,rl=r.unstable_now,y0=r.unstable_getCurrentPriorityLevel,Yf=r.unstable_ImmediatePriority,Gf=r.unstable_UserBlockingPriority,Bu=r.unstable_NormalPriority,m0=r.unstable_LowPriority,Xf=r.unstable_IdlePriority,h0=r.log,g0=r.unstable_setDisableYieldValue,Ya=null,ol=null;function ue(t){if(typeof h0=="function"&&g0(t),ol&&typeof ol.setStrictMode=="function")try{ol.setStrictMode(Ya,t)}catch{}}var sl=Math.clz32?Math.clz32:p0,v0=Math.log,b0=Math.LN2;function p0(t){return t>>>=0,t===0?32:31-(v0(t)/b0|0)|0}var qu=256,Yu=262144,Gu=4194304;function Ce(t){var l=t&42;if(l!==0)return l;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&261888;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function Xu(t,l,e){var a=t.pendingLanes;if(a===0)return 0;var u=0,n=t.suspendedLanes,i=t.pingedLanes;t=t.warmLanes;var c=a&134217727;return c!==0?(a=c&~n,a!==0?u=Ce(a):(i&=c,i!==0?u=Ce(i):e||(e=c&~t,e!==0&&(u=Ce(e))))):(c=a&~n,c!==0?u=Ce(c):i!==0?u=Ce(i):e||(e=a&~t,e!==0&&(u=Ce(e)))),u===0?0:l!==0&&l!==u&&(l&n)===0&&(n=u&-u,e=l&-l,n>=e||n===32&&(e&4194048)!==0)?l:u}function Ga(t,l){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&l)===0}function S0(t,l){switch(t){case 1:case 2:case 4:case 8:case 64:return l+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return l+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Qf(){var t=Gu;return Gu<<=1,(Gu&62914560)===0&&(Gu=4194304),t}function ci(t){for(var l=[],e=0;31>e;e++)l.push(t);return l}function Xa(t,l){t.pendingLanes|=l,l!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function x0(t,l,e,a,u,n){var i=t.pendingLanes;t.pendingLanes=e,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=e,t.entangledLanes&=e,t.errorRecoveryDisabledLanes&=e,t.shellSuspendCounter=0;var c=t.entanglements,o=t.expirationTimes,h=t.hiddenUpdates;for(e=i&~e;0"u")return null;try{return t.activeElement||t.body}catch{return t.body}}var _0=/[\n"\\]/g;function Sl(t){return t.replace(_0,function(l){return"\\"+l.charCodeAt(0).toString(16)+" "})}function yi(t,l,e,a,u,n,i,c){t.name="",i!=null&&typeof i!="function"&&typeof i!="symbol"&&typeof i!="boolean"?t.type=i:t.removeAttribute("type"),l!=null?i==="number"?(l===0&&t.value===""||t.value!=l)&&(t.value=""+pl(l)):t.value!==""+pl(l)&&(t.value=""+pl(l)):i!=="submit"&&i!=="reset"||t.removeAttribute("value"),l!=null?mi(t,i,pl(l)):e!=null?mi(t,i,pl(e)):a!=null&&t.removeAttribute("value"),u==null&&n!=null&&(t.defaultChecked=!!n),u!=null&&(t.checked=u&&typeof u!="function"&&typeof u!="symbol"),c!=null&&typeof c!="function"&&typeof c!="symbol"&&typeof c!="boolean"?t.name=""+pl(c):t.removeAttribute("name")}function tr(t,l,e,a,u,n,i,c){if(n!=null&&typeof n!="function"&&typeof n!="symbol"&&typeof n!="boolean"&&(t.type=n),l!=null||e!=null){if(!(n!=="submit"&&n!=="reset"||l!=null)){di(t);return}e=e!=null?""+pl(e):"",l=l!=null?""+pl(l):e,c||l===t.value||(t.value=l),t.defaultValue=l}a=a??u,a=typeof a!="function"&&typeof a!="symbol"&&!!a,t.checked=c?t.checked:!!a,t.defaultChecked=!!a,i!=null&&typeof i!="function"&&typeof i!="symbol"&&typeof i!="boolean"&&(t.name=i),di(t)}function mi(t,l,e){l==="number"&&wu(t.ownerDocument)===t||t.defaultValue===""+e||(t.defaultValue=""+e)}function ea(t,l,e,a){if(t=t.options,l){l={};for(var u=0;u"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),pi=!1;if(Ql)try{var La={};Object.defineProperty(La,"passive",{get:function(){pi=!0}}),window.addEventListener("test",La,La),window.removeEventListener("test",La,La)}catch{pi=!1}var ie=null,Si=null,Vu=null;function cr(){if(Vu)return Vu;var t,l=Si,e=l.length,a,u="value"in ie?ie.value:ie.textContent,n=u.length;for(t=0;t=Ja),yr=" ",mr=!1;function hr(t,l){switch(t){case"keyup":return ly.indexOf(l.keyCode)!==-1;case"keydown":return l.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function gr(t){return t=t.detail,typeof t=="object"&&"data"in t?t.data:null}var ia=!1;function ay(t,l){switch(t){case"compositionend":return gr(l);case"keypress":return l.which!==32?null:(mr=!0,yr);case"textInput":return t=l.data,t===yr&&mr?null:t;default:return null}}function uy(t,l){if(ia)return t==="compositionend"||!Ai&&hr(t,l)?(t=cr(),Vu=Si=ie=null,ia=!1,t):null;switch(t){case"paste":return null;case"keypress":if(!(l.ctrlKey||l.altKey||l.metaKey)||l.ctrlKey&&l.altKey){if(l.char&&1=l)return{node:e,offset:l-t};t=a}t:{for(;e;){if(e.nextSibling){e=e.nextSibling;break t}e=e.parentNode}e=void 0}e=Er(e)}}function Mr(t,l){return t&&l?t===l?!0:t&&t.nodeType===3?!1:l&&l.nodeType===3?Mr(t,l.parentNode):"contains"in t?t.contains(l):t.compareDocumentPosition?!!(t.compareDocumentPosition(l)&16):!1:!1}function _r(t){t=t!=null&&t.ownerDocument!=null&&t.ownerDocument.defaultView!=null?t.ownerDocument.defaultView:window;for(var l=wu(t.document);l instanceof t.HTMLIFrameElement;){try{var e=typeof l.contentWindow.location.href=="string"}catch{e=!1}if(e)t=l.contentWindow;else break;l=wu(t.document)}return l}function Oi(t){var l=t&&t.nodeName&&t.nodeName.toLowerCase();return l&&(l==="input"&&(t.type==="text"||t.type==="search"||t.type==="tel"||t.type==="url"||t.type==="password")||l==="textarea"||t.contentEditable==="true")}var dy=Ql&&"documentMode"in document&&11>=document.documentMode,ca=null,Ni=null,Fa=null,Di=!1;function Or(t,l,e){var a=e.window===e?e.document:e.nodeType===9?e:e.ownerDocument;Di||ca==null||ca!==wu(a)||(a=ca,"selectionStart"in a&&Oi(a)?a={start:a.selectionStart,end:a.selectionEnd}:(a=(a.ownerDocument&&a.ownerDocument.defaultView||window).getSelection(),a={anchorNode:a.anchorNode,anchorOffset:a.anchorOffset,focusNode:a.focusNode,focusOffset:a.focusOffset}),Fa&&$a(Fa,a)||(Fa=a,a=Gn(Ni,"onSelect"),0>=i,u-=i,Hl=1<<32-sl(l)+u|e<$?(at=Y,Y=null):at=Y.sibling;var rt=g(y,Y,m[$],T);if(rt===null){Y===null&&(Y=at);break}t&&Y&&rt.alternate===null&&l(y,Y),d=n(rt,d,$),ft===null?X=rt:ft.sibling=rt,ft=rt,Y=at}if($===m.length)return e(y,Y),ut&&wl(y,$),X;if(Y===null){for(;$$?(at=Y,Y=null):at=Y.sibling;var Oe=g(y,Y,rt.value,T);if(Oe===null){Y===null&&(Y=at);break}t&&Y&&Oe.alternate===null&&l(y,Y),d=n(Oe,d,$),ft===null?X=Oe:ft.sibling=Oe,ft=Oe,Y=at}if(rt.done)return e(y,Y),ut&&wl(y,$),X;if(Y===null){for(;!rt.done;$++,rt=m.next())rt=E(y,rt.value,T),rt!==null&&(d=n(rt,d,$),ft===null?X=rt:ft.sibling=rt,ft=rt);return ut&&wl(y,$),X}for(Y=a(Y);!rt.done;$++,rt=m.next())rt=b(Y,y,$,rt.value,T),rt!==null&&(t&&rt.alternate!==null&&Y.delete(rt.key===null?$:rt.key),d=n(rt,d,$),ft===null?X=rt:ft.sibling=rt,ft=rt);return t&&Y.forEach(function(Cm){return l(y,Cm)}),ut&&wl(y,$),X}function pt(y,d,m,T){if(typeof m=="object"&&m!==null&&m.type===G&&m.key===null&&(m=m.props.children),typeof m=="object"&&m!==null){switch(m.$$typeof){case st:t:{for(var X=m.key;d!==null;){if(d.key===X){if(X=m.type,X===G){if(d.tag===7){e(y,d.sibling),T=u(d,m.props.children),T.return=y,y=T;break t}}else if(d.elementType===X||typeof X=="object"&&X!==null&&X.$$typeof===Rt&&we(X)===d.type){e(y,d.sibling),T=u(d,m.props),au(T,m),T.return=y,y=T;break t}e(y,d);break}else l(y,d);d=d.sibling}m.type===G?(T=Ye(m.props.children,y.mode,T,m.key),T.return=y,y=T):(T=ln(m.type,m.key,m.props,null,y.mode,T),au(T,m),T.return=y,y=T)}return i(y);case ct:t:{for(X=m.key;d!==null;){if(d.key===X)if(d.tag===4&&d.stateNode.containerInfo===m.containerInfo&&d.stateNode.implementation===m.implementation){e(y,d.sibling),T=u(d,m.children||[]),T.return=y,y=T;break t}else{e(y,d);break}else l(y,d);d=d.sibling}T=qi(m,y.mode,T),T.return=y,y=T}return i(y);case Rt:return m=we(m),pt(y,d,m,T)}if(ll(m))return B(y,d,m,T);if(I(m)){if(X=I(m),typeof X!="function")throw Error(f(150));return m=X.call(m),w(y,d,m,T)}if(typeof m.then=="function")return pt(y,d,rn(m),T);if(m.$$typeof===zt)return pt(y,d,un(y,m),T);on(y,m)}return typeof m=="string"&&m!==""||typeof m=="number"||typeof m=="bigint"?(m=""+m,d!==null&&d.tag===6?(e(y,d.sibling),T=u(d,m),T.return=y,y=T):(e(y,d),T=Bi(m,y.mode,T),T.return=y,y=T),i(y)):e(y,d)}return function(y,d,m,T){try{eu=0;var X=pt(y,d,m,T);return ba=null,X}catch(Y){if(Y===va||Y===cn)throw Y;var ft=yl(29,Y,null,y.mode);return ft.lanes=T,ft.return=y,ft}}}var Ve=Fr(!0),Ir=Fr(!1),se=!1;function Wi(t){t.updateQueue={baseState:t.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function $i(t,l){t=t.updateQueue,l.updateQueue===t&&(l.updateQueue={baseState:t.baseState,firstBaseUpdate:t.firstBaseUpdate,lastBaseUpdate:t.lastBaseUpdate,shared:t.shared,callbacks:null})}function de(t){return{lane:t,tag:0,payload:null,callback:null,next:null}}function ye(t,l,e){var a=t.updateQueue;if(a===null)return null;if(a=a.shared,(ot&2)!==0){var u=a.pending;return u===null?l.next=l:(l.next=u.next,u.next=l),a.pending=l,l=tn(t),Hr(t,null,e),l}return Pu(t,a,l,e),tn(t)}function uu(t,l,e){if(l=l.updateQueue,l!==null&&(l=l.shared,(e&4194048)!==0)){var a=l.lanes;a&=t.pendingLanes,e|=a,l.lanes=e,wf(t,e)}}function Fi(t,l){var e=t.updateQueue,a=t.alternate;if(a!==null&&(a=a.updateQueue,e===a)){var u=null,n=null;if(e=e.firstBaseUpdate,e!==null){do{var i={lane:e.lane,tag:e.tag,payload:e.payload,callback:null,next:null};n===null?u=n=i:n=n.next=i,e=e.next}while(e!==null);n===null?u=n=l:n=n.next=l}else u=n=l;e={baseState:a.baseState,firstBaseUpdate:u,lastBaseUpdate:n,shared:a.shared,callbacks:a.callbacks},t.updateQueue=e;return}t=e.lastBaseUpdate,t===null?e.firstBaseUpdate=l:t.next=l,e.lastBaseUpdate=l}var Ii=!1;function nu(){if(Ii){var t=ga;if(t!==null)throw t}}function iu(t,l,e,a){Ii=!1;var u=t.updateQueue;se=!1;var n=u.firstBaseUpdate,i=u.lastBaseUpdate,c=u.shared.pending;if(c!==null){u.shared.pending=null;var o=c,h=o.next;o.next=null,i===null?n=h:i.next=h,i=o;var z=t.alternate;z!==null&&(z=z.updateQueue,c=z.lastBaseUpdate,c!==i&&(c===null?z.firstBaseUpdate=h:c.next=h,z.lastBaseUpdate=o))}if(n!==null){var E=u.baseState;i=0,z=h=o=null,c=n;do{var g=c.lane&-536870913,b=g!==c.lane;if(b?(et&g)===g:(a&g)===g){g!==0&&g===ha&&(Ii=!0),z!==null&&(z=z.next={lane:0,tag:c.tag,payload:c.payload,callback:null,next:null});t:{var B=t,w=c;g=l;var pt=e;switch(w.tag){case 1:if(B=w.payload,typeof B=="function"){E=B.call(pt,E,g);break t}E=B;break t;case 3:B.flags=B.flags&-65537|128;case 0:if(B=w.payload,g=typeof B=="function"?B.call(pt,E,g):B,g==null)break t;E=H({},E,g);break t;case 2:se=!0}}g=c.callback,g!==null&&(t.flags|=64,b&&(t.flags|=8192),b=u.callbacks,b===null?u.callbacks=[g]:b.push(g))}else b={lane:g,tag:c.tag,payload:c.payload,callback:c.callback,next:null},z===null?(h=z=b,o=E):z=z.next=b,i|=g;if(c=c.next,c===null){if(c=u.shared.pending,c===null)break;b=c,c=b.next,b.next=null,u.lastBaseUpdate=b,u.shared.pending=null}}while(!0);z===null&&(o=E),u.baseState=o,u.firstBaseUpdate=h,u.lastBaseUpdate=z,n===null&&(u.shared.lanes=0),be|=i,t.lanes=i,t.memoizedState=E}}function Pr(t,l){if(typeof t!="function")throw Error(f(191,t));t.call(l)}function to(t,l){var e=t.callbacks;if(e!==null)for(t.callbacks=null,t=0;tn?n:8;var i=x.T,c={};x.T=c,vc(t,!1,l,e);try{var o=u(),h=x.S;if(h!==null&&h(c,o),o!==null&&typeof o=="object"&&typeof o.then=="function"){var z=xy(o,a);ru(t,l,z,bl(t))}else ru(t,l,a,bl(t))}catch(E){ru(t,l,{then:function(){},status:"rejected",reason:E},bl())}finally{C.p=n,i!==null&&c.types!==null&&(i.types=c.types),x.T=i}}function _y(){}function hc(t,l,e,a){if(t.tag!==5)throw Error(f(476));var u=jo(t).queue;Co(t,u,l,Z,e===null?_y:function(){return Ro(t),e(a)})}function jo(t){var l=t.memoizedState;if(l!==null)return l;l={memoizedState:Z,baseState:Z,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Jl,lastRenderedState:Z},next:null};var e={};return l.next={memoizedState:e,baseState:e,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Jl,lastRenderedState:e},next:null},t.memoizedState=l,t=t.alternate,t!==null&&(t.memoizedState=l),l}function Ro(t){var l=jo(t);l.next===null&&(l=t.alternate.memoizedState),ru(t,l.next.queue,{},bl())}function gc(){return Vt(Mu)}function Ho(){return jt().memoizedState}function Bo(){return jt().memoizedState}function Oy(t){for(var l=t.return;l!==null;){switch(l.tag){case 24:case 3:var e=bl();t=de(e);var a=ye(l,t,e);a!==null&&(fl(a,l,e),uu(a,l,e)),l={cache:Vi()},t.payload=l;return}l=l.return}}function Ny(t,l,e){var a=bl();e={lane:a,revertLane:0,gesture:null,action:e,hasEagerState:!1,eagerState:null,next:null},Sn(t)?Yo(l,e):(e=Ri(t,l,e,a),e!==null&&(fl(e,t,a),Go(e,l,a)))}function qo(t,l,e){var a=bl();ru(t,l,e,a)}function ru(t,l,e,a){var u={lane:a,revertLane:0,gesture:null,action:e,hasEagerState:!1,eagerState:null,next:null};if(Sn(t))Yo(l,u);else{var n=t.alternate;if(t.lanes===0&&(n===null||n.lanes===0)&&(n=l.lastRenderedReducer,n!==null))try{var i=l.lastRenderedState,c=n(i,e);if(u.hasEagerState=!0,u.eagerState=c,dl(c,i))return Pu(t,l,u,0),St===null&&Iu(),!1}catch{}if(e=Ri(t,l,u,a),e!==null)return fl(e,t,a),Go(e,l,a),!0}return!1}function vc(t,l,e,a){if(a={lane:2,revertLane:Wc(),gesture:null,action:a,hasEagerState:!1,eagerState:null,next:null},Sn(t)){if(l)throw Error(f(479))}else l=Ri(t,e,a,2),l!==null&&fl(l,t,2)}function Sn(t){var l=t.alternate;return t===W||l!==null&&l===W}function Yo(t,l){Sa=yn=!0;var e=t.pending;e===null?l.next=l:(l.next=e.next,e.next=l),t.pending=l}function Go(t,l,e){if((e&4194048)!==0){var a=l.lanes;a&=t.pendingLanes,e|=a,l.lanes=e,wf(t,e)}}var ou={readContext:Vt,use:gn,useCallback:Nt,useContext:Nt,useEffect:Nt,useImperativeHandle:Nt,useLayoutEffect:Nt,useInsertionEffect:Nt,useMemo:Nt,useReducer:Nt,useRef:Nt,useState:Nt,useDebugValue:Nt,useDeferredValue:Nt,useTransition:Nt,useSyncExternalStore:Nt,useId:Nt,useHostTransitionStatus:Nt,useFormState:Nt,useActionState:Nt,useOptimistic:Nt,useMemoCache:Nt,useCacheRefresh:Nt};ou.useEffectEvent=Nt;var Xo={readContext:Vt,use:gn,useCallback:function(t,l){return $t().memoizedState=[t,l===void 0?null:l],t},useContext:Vt,useEffect:To,useImperativeHandle:function(t,l,e){e=e!=null?e.concat([t]):null,bn(4194308,4,_o.bind(null,l,t),e)},useLayoutEffect:function(t,l){return bn(4194308,4,t,l)},useInsertionEffect:function(t,l){bn(4,2,t,l)},useMemo:function(t,l){var e=$t();l=l===void 0?null:l;var a=t();if(Ke){ue(!0);try{t()}finally{ue(!1)}}return e.memoizedState=[a,l],a},useReducer:function(t,l,e){var a=$t();if(e!==void 0){var u=e(l);if(Ke){ue(!0);try{e(l)}finally{ue(!1)}}}else u=l;return a.memoizedState=a.baseState=u,t={pending:null,lanes:0,dispatch:null,lastRenderedReducer:t,lastRenderedState:u},a.queue=t,t=t.dispatch=Ny.bind(null,W,t),[a.memoizedState,t]},useRef:function(t){var l=$t();return t={current:t},l.memoizedState=t},useState:function(t){t=oc(t);var l=t.queue,e=qo.bind(null,W,l);return l.dispatch=e,[t.memoizedState,e]},useDebugValue:yc,useDeferredValue:function(t,l){var e=$t();return mc(e,t,l)},useTransition:function(){var t=oc(!1);return t=Co.bind(null,W,t.queue,!0,!1),$t().memoizedState=t,[!1,t]},useSyncExternalStore:function(t,l,e){var a=W,u=$t();if(ut){if(e===void 0)throw Error(f(407));e=e()}else{if(e=l(),St===null)throw Error(f(349));(et&127)!==0||io(a,l,e)}u.memoizedState=e;var n={value:e,getSnapshot:l};return u.queue=n,To(fo.bind(null,a,n,t),[t]),a.flags|=2048,za(9,{destroy:void 0},co.bind(null,a,n,e,l),null),e},useId:function(){var t=$t(),l=St.identifierPrefix;if(ut){var e=Bl,a=Hl;e=(a&~(1<<32-sl(a)-1)).toString(32)+e,l="_"+l+"R_"+e,e=mn++,0<\/script>",n=n.removeChild(n.firstChild);break;case"select":n=typeof a.is=="string"?i.createElement("select",{is:a.is}):i.createElement("select"),a.multiple?n.multiple=!0:a.size&&(n.size=a.size);break;default:n=typeof a.is=="string"?i.createElement(u,{is:a.is}):i.createElement(u)}}n[wt]=l,n[el]=a;t:for(i=l.child;i!==null;){if(i.tag===5||i.tag===6)n.appendChild(i.stateNode);else if(i.tag!==4&&i.tag!==27&&i.child!==null){i.child.return=i,i=i.child;continue}if(i===l)break t;for(;i.sibling===null;){if(i.return===null||i.return===l)break t;i=i.return}i.sibling.return=i.return,i=i.sibling}l.stateNode=n;t:switch(Jt(n,u,a),u){case"button":case"input":case"select":case"textarea":a=!!a.autoFocus;break t;case"img":a=!0;break t;default:a=!1}a&&Wl(l)}}return Et(l),Uc(l,l.type,t===null?null:t.memoizedProps,l.pendingProps,e),null;case 6:if(t&&l.stateNode!=null)t.memoizedProps!==a&&Wl(l);else{if(typeof a!="string"&&l.stateNode===null)throw Error(f(166));if(t=P.current,ya(l)){if(t=l.stateNode,e=l.memoizedProps,a=null,u=Lt,u!==null)switch(u.tag){case 27:case 5:a=u.memoizedProps}t[wt]=l,t=!!(t.nodeValue===e||a!==null&&a.suppressHydrationWarning===!0||nd(t.nodeValue,e)),t||re(l,!0)}else t=Xn(t).createTextNode(a),t[wt]=l,l.stateNode=t}return Et(l),null;case 31:if(e=l.memoizedState,t===null||t.memoizedState!==null){if(a=ya(l),e!==null){if(t===null){if(!a)throw Error(f(318));if(t=l.memoizedState,t=t!==null?t.dehydrated:null,!t)throw Error(f(557));t[wt]=l}else Ge(),(l.flags&128)===0&&(l.memoizedState=null),l.flags|=4;Et(l),t=!1}else e=Qi(),t!==null&&t.memoizedState!==null&&(t.memoizedState.hydrationErrors=e),t=!0;if(!t)return l.flags&256?(hl(l),l):(hl(l),null);if((l.flags&128)!==0)throw Error(f(558))}return Et(l),null;case 13:if(a=l.memoizedState,t===null||t.memoizedState!==null&&t.memoizedState.dehydrated!==null){if(u=ya(l),a!==null&&a.dehydrated!==null){if(t===null){if(!u)throw Error(f(318));if(u=l.memoizedState,u=u!==null?u.dehydrated:null,!u)throw Error(f(317));u[wt]=l}else Ge(),(l.flags&128)===0&&(l.memoizedState=null),l.flags|=4;Et(l),u=!1}else u=Qi(),t!==null&&t.memoizedState!==null&&(t.memoizedState.hydrationErrors=u),u=!0;if(!u)return l.flags&256?(hl(l),l):(hl(l),null)}return hl(l),(l.flags&128)!==0?(l.lanes=e,l):(e=a!==null,t=t!==null&&t.memoizedState!==null,e&&(a=l.child,u=null,a.alternate!==null&&a.alternate.memoizedState!==null&&a.alternate.memoizedState.cachePool!==null&&(u=a.alternate.memoizedState.cachePool.pool),n=null,a.memoizedState!==null&&a.memoizedState.cachePool!==null&&(n=a.memoizedState.cachePool.pool),n!==u&&(a.flags|=2048)),e!==t&&e&&(l.child.flags|=8192),An(l,l.updateQueue),Et(l),null);case 4:return Ut(),t===null&&Pc(l.stateNode.containerInfo),Et(l),null;case 10:return Vl(l.type),Et(l),null;case 19:if(M(Ct),a=l.memoizedState,a===null)return Et(l),null;if(u=(l.flags&128)!==0,n=a.rendering,n===null)if(u)du(a,!1);else{if(Dt!==0||t!==null&&(t.flags&128)!==0)for(t=l.child;t!==null;){if(n=dn(t),n!==null){for(l.flags|=128,du(a,!1),t=n.updateQueue,l.updateQueue=t,An(l,t),l.subtreeFlags=0,t=e,e=l.child;e!==null;)Br(e,t),e=e.sibling;return j(Ct,Ct.current&1|2),ut&&wl(l,a.treeForkCount),l.child}t=t.sibling}a.tail!==null&&rl()>Dn&&(l.flags|=128,u=!0,du(a,!1),l.lanes=4194304)}else{if(!u)if(t=dn(n),t!==null){if(l.flags|=128,u=!0,t=t.updateQueue,l.updateQueue=t,An(l,t),du(a,!0),a.tail===null&&a.tailMode==="hidden"&&!n.alternate&&!ut)return Et(l),null}else 2*rl()-a.renderingStartTime>Dn&&e!==536870912&&(l.flags|=128,u=!0,du(a,!1),l.lanes=4194304);a.isBackwards?(n.sibling=l.child,l.child=n):(t=a.last,t!==null?t.sibling=n:l.child=n,a.last=n)}return a.tail!==null?(t=a.tail,a.rendering=t,a.tail=t.sibling,a.renderingStartTime=rl(),t.sibling=null,e=Ct.current,j(Ct,u?e&1|2:e&1),ut&&wl(l,a.treeForkCount),t):(Et(l),null);case 22:case 23:return hl(l),tc(),a=l.memoizedState!==null,t!==null?t.memoizedState!==null!==a&&(l.flags|=8192):a&&(l.flags|=8192),a?(e&536870912)!==0&&(l.flags&128)===0&&(Et(l),l.subtreeFlags&6&&(l.flags|=8192)):Et(l),e=l.updateQueue,e!==null&&An(l,e.retryQueue),e=null,t!==null&&t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(e=t.memoizedState.cachePool.pool),a=null,l.memoizedState!==null&&l.memoizedState.cachePool!==null&&(a=l.memoizedState.cachePool.pool),a!==e&&(l.flags|=2048),t!==null&&M(Ze),null;case 24:return e=null,t!==null&&(e=t.memoizedState.cache),l.memoizedState.cache!==e&&(l.flags|=2048),Vl(Ht),Et(l),null;case 25:return null;case 30:return null}throw Error(f(156,l.tag))}function Ry(t,l){switch(Gi(l),l.tag){case 1:return t=l.flags,t&65536?(l.flags=t&-65537|128,l):null;case 3:return Vl(Ht),Ut(),t=l.flags,(t&65536)!==0&&(t&128)===0?(l.flags=t&-65537|128,l):null;case 26:case 27:case 5:return Hu(l),null;case 31:if(l.memoizedState!==null){if(hl(l),l.alternate===null)throw Error(f(340));Ge()}return t=l.flags,t&65536?(l.flags=t&-65537|128,l):null;case 13:if(hl(l),t=l.memoizedState,t!==null&&t.dehydrated!==null){if(l.alternate===null)throw Error(f(340));Ge()}return t=l.flags,t&65536?(l.flags=t&-65537|128,l):null;case 19:return M(Ct),null;case 4:return Ut(),null;case 10:return Vl(l.type),null;case 22:case 23:return hl(l),tc(),t!==null&&M(Ze),t=l.flags,t&65536?(l.flags=t&-65537|128,l):null;case 24:return Vl(Ht),null;case 25:return null;default:return null}}function os(t,l){switch(Gi(l),l.tag){case 3:Vl(Ht),Ut();break;case 26:case 27:case 5:Hu(l);break;case 4:Ut();break;case 31:l.memoizedState!==null&&hl(l);break;case 13:hl(l);break;case 19:M(Ct);break;case 10:Vl(l.type);break;case 22:case 23:hl(l),tc(),t!==null&&M(Ze);break;case 24:Vl(Ht)}}function yu(t,l){try{var e=l.updateQueue,a=e!==null?e.lastEffect:null;if(a!==null){var u=a.next;e=u;do{if((e.tag&t)===t){a=void 0;var n=e.create,i=e.inst;a=n(),i.destroy=a}e=e.next}while(e!==u)}}catch(c){ht(l,l.return,c)}}function ge(t,l,e){try{var a=l.updateQueue,u=a!==null?a.lastEffect:null;if(u!==null){var n=u.next;a=n;do{if((a.tag&t)===t){var i=a.inst,c=i.destroy;if(c!==void 0){i.destroy=void 0,u=l;var o=e,h=c;try{h()}catch(z){ht(u,o,z)}}}a=a.next}while(a!==n)}}catch(z){ht(l,l.return,z)}}function ss(t){var l=t.updateQueue;if(l!==null){var e=t.stateNode;try{to(l,e)}catch(a){ht(t,t.return,a)}}}function ds(t,l,e){e.props=Je(t.type,t.memoizedProps),e.state=t.memoizedState;try{e.componentWillUnmount()}catch(a){ht(t,l,a)}}function mu(t,l){try{var e=t.ref;if(e!==null){switch(t.tag){case 26:case 27:case 5:var a=t.stateNode;break;case 30:a=t.stateNode;break;default:a=t.stateNode}typeof e=="function"?t.refCleanup=e(a):e.current=a}}catch(u){ht(t,l,u)}}function ql(t,l){var e=t.ref,a=t.refCleanup;if(e!==null)if(typeof a=="function")try{a()}catch(u){ht(t,l,u)}finally{t.refCleanup=null,t=t.alternate,t!=null&&(t.refCleanup=null)}else if(typeof e=="function")try{e(null)}catch(u){ht(t,l,u)}else e.current=null}function ys(t){var l=t.type,e=t.memoizedProps,a=t.stateNode;try{t:switch(l){case"button":case"input":case"select":case"textarea":e.autoFocus&&a.focus();break t;case"img":e.src?a.src=e.src:e.srcSet&&(a.srcset=e.srcSet)}}catch(u){ht(t,t.return,u)}}function Cc(t,l,e){try{var a=t.stateNode;em(a,t.type,e,l),a[el]=l}catch(u){ht(t,t.return,u)}}function ms(t){return t.tag===5||t.tag===3||t.tag===26||t.tag===27&&Te(t.type)||t.tag===4}function jc(t){t:for(;;){for(;t.sibling===null;){if(t.return===null||ms(t.return))return null;t=t.return}for(t.sibling.return=t.return,t=t.sibling;t.tag!==5&&t.tag!==6&&t.tag!==18;){if(t.tag===27&&Te(t.type)||t.flags&2||t.child===null||t.tag===4)continue t;t.child.return=t,t=t.child}if(!(t.flags&2))return t.stateNode}}function Rc(t,l,e){var a=t.tag;if(a===5||a===6)t=t.stateNode,l?(e.nodeType===9?e.body:e.nodeName==="HTML"?e.ownerDocument.body:e).insertBefore(t,l):(l=e.nodeType===9?e.body:e.nodeName==="HTML"?e.ownerDocument.body:e,l.appendChild(t),e=e._reactRootContainer,e!=null||l.onclick!==null||(l.onclick=Xl));else if(a!==4&&(a===27&&Te(t.type)&&(e=t.stateNode,l=null),t=t.child,t!==null))for(Rc(t,l,e),t=t.sibling;t!==null;)Rc(t,l,e),t=t.sibling}function Mn(t,l,e){var a=t.tag;if(a===5||a===6)t=t.stateNode,l?e.insertBefore(t,l):e.appendChild(t);else if(a!==4&&(a===27&&Te(t.type)&&(e=t.stateNode),t=t.child,t!==null))for(Mn(t,l,e),t=t.sibling;t!==null;)Mn(t,l,e),t=t.sibling}function hs(t){var l=t.stateNode,e=t.memoizedProps;try{for(var a=t.type,u=l.attributes;u.length;)l.removeAttributeNode(u[0]);Jt(l,a,e),l[wt]=t,l[el]=e}catch(n){ht(t,t.return,n)}}var $l=!1,Yt=!1,Hc=!1,gs=typeof WeakSet=="function"?WeakSet:Set,Qt=null;function Hy(t,l){if(t=t.containerInfo,ef=Jn,t=_r(t),Oi(t)){if("selectionStart"in t)var e={start:t.selectionStart,end:t.selectionEnd};else t:{e=(e=t.ownerDocument)&&e.defaultView||window;var a=e.getSelection&&e.getSelection();if(a&&a.rangeCount!==0){e=a.anchorNode;var u=a.anchorOffset,n=a.focusNode;a=a.focusOffset;try{e.nodeType,n.nodeType}catch{e=null;break t}var i=0,c=-1,o=-1,h=0,z=0,E=t,g=null;l:for(;;){for(var b;E!==e||u!==0&&E.nodeType!==3||(c=i+u),E!==n||a!==0&&E.nodeType!==3||(o=i+a),E.nodeType===3&&(i+=E.nodeValue.length),(b=E.firstChild)!==null;)g=E,E=b;for(;;){if(E===t)break l;if(g===e&&++h===u&&(c=i),g===n&&++z===a&&(o=i),(b=E.nextSibling)!==null)break;E=g,g=E.parentNode}E=b}e=c===-1||o===-1?null:{start:c,end:o}}else e=null}e=e||{start:0,end:0}}else e=null;for(af={focusedElem:t,selectionRange:e},Jn=!1,Qt=l;Qt!==null;)if(l=Qt,t=l.child,(l.subtreeFlags&1028)!==0&&t!==null)t.return=l,Qt=t;else for(;Qt!==null;){switch(l=Qt,n=l.alternate,t=l.flags,l.tag){case 0:if((t&4)!==0&&(t=l.updateQueue,t=t!==null?t.events:null,t!==null))for(e=0;e title"))),Jt(n,a,e),n[wt]=t,Xt(n),a=n;break t;case"link":var i=zd("link","href",u).get(a+(e.href||""));if(i){for(var c=0;cpt&&(i=pt,pt=w,w=i);var y=Ar(c,w),d=Ar(c,pt);if(y&&d&&(b.rangeCount!==1||b.anchorNode!==y.node||b.anchorOffset!==y.offset||b.focusNode!==d.node||b.focusOffset!==d.offset)){var m=E.createRange();m.setStart(y.node,y.offset),b.removeAllRanges(),w>pt?(b.addRange(m),b.extend(d.node,d.offset)):(m.setEnd(d.node,d.offset),b.addRange(m))}}}}for(E=[],b=c;b=b.parentNode;)b.nodeType===1&&E.push({element:b,left:b.scrollLeft,top:b.scrollTop});for(typeof c.focus=="function"&&c.focus(),c=0;ce?32:e,x.T=null,e=Zc,Zc=null;var n=Se,i=le;if(Gt=0,_a=Se=null,le=0,(ot&6)!==0)throw Error(f(331));var c=ot;if(ot|=4,_s(n.current),Es(n,n.current,i,e),ot=c,Su(0,!1),ol&&typeof ol.onPostCommitFiberRoot=="function")try{ol.onPostCommitFiberRoot(Ya,n)}catch{}return!0}finally{C.p=u,x.T=a,Vs(t,l)}}function Js(t,l,e){l=zl(e,l),l=xc(t.stateNode,l,2),t=ye(t,l,2),t!==null&&(Xa(t,2),Yl(t))}function ht(t,l,e){if(t.tag===3)Js(t,t,e);else for(;l!==null;){if(l.tag===3){Js(l,t,e);break}else if(l.tag===1){var a=l.stateNode;if(typeof l.type.getDerivedStateFromError=="function"||typeof a.componentDidCatch=="function"&&(pe===null||!pe.has(a))){t=zl(e,t),e=ko(2),a=ye(l,e,2),a!==null&&(Wo(e,a,l,t),Xa(a,2),Yl(a));break}}l=l.return}}function Kc(t,l,e){var a=t.pingCache;if(a===null){a=t.pingCache=new Yy;var u=new Set;a.set(l,u)}else u=a.get(l),u===void 0&&(u=new Set,a.set(l,u));u.has(e)||(Yc=!0,u.add(e),t=wy.bind(null,t,l,e),l.then(t,t))}function wy(t,l,e){var a=t.pingCache;a!==null&&a.delete(l),t.pingedLanes|=t.suspendedLanes&e,t.warmLanes&=~e,St===t&&(et&e)===e&&(Dt===4||Dt===3&&(et&62914560)===et&&300>rl()-Nn?(ot&2)===0&&Oa(t,0):Gc|=e,Ma===et&&(Ma=0)),Yl(t)}function ks(t,l){l===0&&(l=Qf()),t=qe(t,l),t!==null&&(Xa(t,l),Yl(t))}function Ly(t){var l=t.memoizedState,e=0;l!==null&&(e=l.retryLane),ks(t,e)}function Vy(t,l){var e=0;switch(t.tag){case 31:case 13:var a=t.stateNode,u=t.memoizedState;u!==null&&(e=u.retryLane);break;case 19:a=t.stateNode;break;case 22:a=t.stateNode._retryCache;break;default:throw Error(f(314))}a!==null&&a.delete(l),ks(t,e)}function Ky(t,l){return ni(t,l)}var Bn=null,Da=null,Jc=!1,qn=!1,kc=!1,ze=0;function Yl(t){t!==Da&&t.next===null&&(Da===null?Bn=Da=t:Da=Da.next=t),qn=!0,Jc||(Jc=!0,ky())}function Su(t,l){if(!kc&&qn){kc=!0;do for(var e=!1,a=Bn;a!==null;){if(t!==0){var u=a.pendingLanes;if(u===0)var n=0;else{var i=a.suspendedLanes,c=a.pingedLanes;n=(1<<31-sl(42|t)+1)-1,n&=u&~(i&~c),n=n&201326741?n&201326741|1:n?n|2:0}n!==0&&(e=!0,Is(a,n))}else n=et,n=Xu(a,a===St?n:0,a.cancelPendingCommit!==null||a.timeoutHandle!==-1),(n&3)===0||Ga(a,n)||(e=!0,Is(a,n));a=a.next}while(e);kc=!1}}function Jy(){Ws()}function Ws(){qn=Jc=!1;var t=0;ze!==0&&um()&&(t=ze);for(var l=rl(),e=null,a=Bn;a!==null;){var u=a.next,n=$s(a,l);n===0?(a.next=null,e===null?Bn=u:e.next=u,u===null&&(Da=e)):(e=a,(t!==0||(n&3)!==0)&&(qn=!0)),a=u}Gt!==0&&Gt!==5||Su(t),ze!==0&&(ze=0)}function $s(t,l){for(var e=t.suspendedLanes,a=t.pingedLanes,u=t.expirationTimes,n=t.pendingLanes&-62914561;0c)break;var z=o.transferSize,E=o.initiatorType;z&&id(E)&&(o=o.responseEnd,i+=z*(o"u"?null:document;function bd(t,l,e){var a=Ua;if(a&&typeof l=="string"&&l){var u=Sl(l);u='link[rel="'+t+'"][href="'+u+'"]',typeof e=="string"&&(u+='[crossorigin="'+e+'"]'),vd.has(u)||(vd.add(u),t={rel:t,crossOrigin:e,href:l},a.querySelector(u)===null&&(l=a.createElement("link"),Jt(l,"link",t),Xt(l),a.head.appendChild(l)))}}function ym(t){ee.D(t),bd("dns-prefetch",t,null)}function mm(t,l){ee.C(t,l),bd("preconnect",t,l)}function hm(t,l,e){ee.L(t,l,e);var a=Ua;if(a&&t&&l){var u='link[rel="preload"][as="'+Sl(l)+'"]';l==="image"&&e&&e.imageSrcSet?(u+='[imagesrcset="'+Sl(e.imageSrcSet)+'"]',typeof e.imageSizes=="string"&&(u+='[imagesizes="'+Sl(e.imageSizes)+'"]')):u+='[href="'+Sl(t)+'"]';var n=u;switch(l){case"style":n=Ca(t);break;case"script":n=ja(t)}Ol.has(n)||(t=H({rel:"preload",href:l==="image"&&e&&e.imageSrcSet?void 0:t,as:l},e),Ol.set(n,t),a.querySelector(u)!==null||l==="style"&&a.querySelector(Eu(n))||l==="script"&&a.querySelector(Au(n))||(l=a.createElement("link"),Jt(l,"link",t),Xt(l),a.head.appendChild(l)))}}function gm(t,l){ee.m(t,l);var e=Ua;if(e&&t){var a=l&&typeof l.as=="string"?l.as:"script",u='link[rel="modulepreload"][as="'+Sl(a)+'"][href="'+Sl(t)+'"]',n=u;switch(a){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":n=ja(t)}if(!Ol.has(n)&&(t=H({rel:"modulepreload",href:t},l),Ol.set(n,t),e.querySelector(u)===null)){switch(a){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(e.querySelector(Au(n)))return}a=e.createElement("link"),Jt(a,"link",t),Xt(a),e.head.appendChild(a)}}}function vm(t,l,e){ee.S(t,l,e);var a=Ua;if(a&&t){var u=ta(a).hoistableStyles,n=Ca(t);l=l||"default";var i=u.get(n);if(!i){var c={loading:0,preload:null};if(i=a.querySelector(Eu(n)))c.loading=5;else{t=H({rel:"stylesheet",href:t,"data-precedence":l},e),(e=Ol.get(n))&&sf(t,e);var o=i=a.createElement("link");Xt(o),Jt(o,"link",t),o._p=new Promise(function(h,z){o.onload=h,o.onerror=z}),o.addEventListener("load",function(){c.loading|=1}),o.addEventListener("error",function(){c.loading|=2}),c.loading|=4,Zn(i,l,a)}i={type:"stylesheet",instance:i,count:1,state:c},u.set(n,i)}}}function bm(t,l){ee.X(t,l);var e=Ua;if(e&&t){var a=ta(e).hoistableScripts,u=ja(t),n=a.get(u);n||(n=e.querySelector(Au(u)),n||(t=H({src:t,async:!0},l),(l=Ol.get(u))&&df(t,l),n=e.createElement("script"),Xt(n),Jt(n,"link",t),e.head.appendChild(n)),n={type:"script",instance:n,count:1,state:null},a.set(u,n))}}function pm(t,l){ee.M(t,l);var e=Ua;if(e&&t){var a=ta(e).hoistableScripts,u=ja(t),n=a.get(u);n||(n=e.querySelector(Au(u)),n||(t=H({src:t,async:!0,type:"module"},l),(l=Ol.get(u))&&df(t,l),n=e.createElement("script"),Xt(n),Jt(n,"link",t),e.head.appendChild(n)),n={type:"script",instance:n,count:1,state:null},a.set(u,n))}}function pd(t,l,e,a){var u=(u=P.current)?Qn(u):null;if(!u)throw Error(f(446));switch(t){case"meta":case"title":return null;case"style":return typeof e.precedence=="string"&&typeof e.href=="string"?(l=Ca(e.href),e=ta(u).hoistableStyles,a=e.get(l),a||(a={type:"style",instance:null,count:0,state:null},e.set(l,a)),a):{type:"void",instance:null,count:0,state:null};case"link":if(e.rel==="stylesheet"&&typeof e.href=="string"&&typeof e.precedence=="string"){t=Ca(e.href);var n=ta(u).hoistableStyles,i=n.get(t);if(i||(u=u.ownerDocument||u,i={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},n.set(t,i),(n=u.querySelector(Eu(t)))&&!n._p&&(i.instance=n,i.state.loading=5),Ol.has(t)||(e={rel:"preload",as:"style",href:e.href,crossOrigin:e.crossOrigin,integrity:e.integrity,media:e.media,hrefLang:e.hrefLang,referrerPolicy:e.referrerPolicy},Ol.set(t,e),n||Sm(u,t,e,i.state))),l&&a===null)throw Error(f(528,""));return i}if(l&&a!==null)throw Error(f(529,""));return null;case"script":return l=e.async,e=e.src,typeof e=="string"&&l&&typeof l!="function"&&typeof l!="symbol"?(l=ja(e),e=ta(u).hoistableScripts,a=e.get(l),a||(a={type:"script",instance:null,count:0,state:null},e.set(l,a)),a):{type:"void",instance:null,count:0,state:null};default:throw Error(f(444,t))}}function Ca(t){return'href="'+Sl(t)+'"'}function Eu(t){return'link[rel="stylesheet"]['+t+"]"}function Sd(t){return H({},t,{"data-precedence":t.precedence,precedence:null})}function Sm(t,l,e,a){t.querySelector('link[rel="preload"][as="style"]['+l+"]")?a.loading=1:(l=t.createElement("link"),a.preload=l,l.addEventListener("load",function(){return a.loading|=1}),l.addEventListener("error",function(){return a.loading|=2}),Jt(l,"link",e),Xt(l),t.head.appendChild(l))}function ja(t){return'[src="'+Sl(t)+'"]'}function Au(t){return"script[async]"+t}function xd(t,l,e){if(l.count++,l.instance===null)switch(l.type){case"style":var a=t.querySelector('style[data-href~="'+Sl(e.href)+'"]');if(a)return l.instance=a,Xt(a),a;var u=H({},e,{"data-href":e.href,"data-precedence":e.precedence,href:null,precedence:null});return a=(t.ownerDocument||t).createElement("style"),Xt(a),Jt(a,"style",u),Zn(a,e.precedence,t),l.instance=a;case"stylesheet":u=Ca(e.href);var n=t.querySelector(Eu(u));if(n)return l.state.loading|=4,l.instance=n,Xt(n),n;a=Sd(e),(u=Ol.get(u))&&sf(a,u),n=(t.ownerDocument||t).createElement("link"),Xt(n);var i=n;return i._p=new Promise(function(c,o){i.onload=c,i.onerror=o}),Jt(n,"link",a),l.state.loading|=4,Zn(n,e.precedence,t),l.instance=n;case"script":return n=ja(e.src),(u=t.querySelector(Au(n)))?(l.instance=u,Xt(u),u):(a=e,(u=Ol.get(n))&&(a=H({},e),df(a,u)),t=t.ownerDocument||t,u=t.createElement("script"),Xt(u),Jt(u,"link",a),t.head.appendChild(u),l.instance=u);case"void":return null;default:throw Error(f(443,l.type))}else l.type==="stylesheet"&&(l.state.loading&4)===0&&(a=l.instance,l.state.loading|=4,Zn(a,e.precedence,t));return l.instance}function Zn(t,l,e){for(var a=e.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),u=a.length?a[a.length-1]:null,n=u,i=0;i title"):null)}function xm(t,l,e){if(e===1||l.itemProp!=null)return!1;switch(t){case"meta":case"title":return!0;case"style":if(typeof l.precedence!="string"||typeof l.href!="string"||l.href==="")break;return!0;case"link":if(typeof l.rel!="string"||typeof l.href!="string"||l.href===""||l.onLoad||l.onError)break;return l.rel==="stylesheet"?(t=l.disabled,typeof l.precedence=="string"&&t==null):!0;case"script":if(l.async&&typeof l.async!="function"&&typeof l.async!="symbol"&&!l.onLoad&&!l.onError&&l.src&&typeof l.src=="string")return!0}return!1}function Ed(t){return!(t.type==="stylesheet"&&(t.state.loading&3)===0)}function zm(t,l,e,a){if(e.type==="stylesheet"&&(typeof a.media!="string"||matchMedia(a.media).matches!==!1)&&(e.state.loading&4)===0){if(e.instance===null){var u=Ca(a.href),n=l.querySelector(Eu(u));if(n){l=n._p,l!==null&&typeof l=="object"&&typeof l.then=="function"&&(t.count++,t=Ln.bind(t),l.then(t,t)),e.state.loading|=4,e.instance=n,Xt(n);return}n=l.ownerDocument||l,a=Sd(a),(u=Ol.get(u))&&sf(a,u),n=n.createElement("link"),Xt(n);var i=n;i._p=new Promise(function(c,o){i.onload=c,i.onerror=o}),Jt(n,"link",a),e.instance=n}t.stylesheets===null&&(t.stylesheets=new Map),t.stylesheets.set(e,l),(l=e.state.preload)&&(e.state.loading&3)===0&&(t.count++,e=Ln.bind(t),l.addEventListener("load",e),l.addEventListener("error",e))}}var yf=0;function Tm(t,l){return t.stylesheets&&t.count===0&&Kn(t,t.stylesheets),0yf?50:800)+l);return t.unsuspend=e,function(){t.unsuspend=null,clearTimeout(a),clearTimeout(u)}}:null}function Ln(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Kn(this,this.stylesheets);else if(this.unsuspend){var t=this.unsuspend;this.unsuspend=null,t()}}}var Vn=null;function Kn(t,l){t.stylesheets=null,t.unsuspend!==null&&(t.count++,Vn=new Map,l.forEach(Em,t),Vn=null,Ln.call(t))}function Em(t,l){if(!(l.state.loading&4)){var e=Vn.get(t);if(e)var a=e.get(null);else{e=new Map,Vn.set(t,e);for(var u=t.querySelectorAll("link[data-precedence],style[data-precedence]"),n=0;n"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(r)}catch(v){console.error(v)}}return r(),zf.exports=Xm(),zf.exports}var Zm=Qm();const wm=r=>r.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase(),Pd=(...r)=>r.filter((v,S,f)=>!!v&&v.trim()!==""&&f.indexOf(v)===S).join(" ").trim();var Lm={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"};const Vm=xt.forwardRef(({color:r="currentColor",size:v=24,strokeWidth:S=2,absoluteStrokeWidth:f,className:_="",children:O,iconNode:D,...U},N)=>xt.createElement("svg",{ref:N,...Lm,width:v,height:v,stroke:r,strokeWidth:f?Number(S)*24/Number(v):S,className:Pd("lucide",_),...U},[...D.map(([p,R])=>xt.createElement(p,R)),...Array.isArray(O)?O:[O]]));const Nl=(r,v)=>{const S=xt.forwardRef(({className:f,..._},O)=>xt.createElement(Vm,{ref:O,iconNode:v,className:Pd(`lucide-${wm(r)}`,f),..._}));return S.displayName=`${r}`,S};const Km=Nl("Binary",[["rect",{x:"14",y:"14",width:"4",height:"6",rx:"2",key:"p02svl"}],["rect",{x:"6",y:"4",width:"4",height:"6",rx:"2",key:"xm4xkj"}],["path",{d:"M6 20h4",key:"1i6q5t"}],["path",{d:"M14 10h4",key:"ru81e7"}],["path",{d:"M6 14h2v6",key:"16z9wg"}],["path",{d:"M14 4h2v6",key:"1idq9u"}]]);const Jm=Nl("BookText",[["path",{d:"M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20",key:"k3hazp"}],["path",{d:"M8 11h8",key:"vwpz6n"}],["path",{d:"M8 7h6",key:"1f0q6e"}]]);const km=Nl("EyeOff",[["path",{d:"M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49",key:"ct8e1f"}],["path",{d:"M14.084 14.158a3 3 0 0 1-4.242-4.242",key:"151rxh"}],["path",{d:"M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143",key:"13bj9a"}],["path",{d:"m2 2 20 20",key:"1ooewy"}]]);const Wm=Nl("Eye",[["path",{d:"M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0",key:"1nclc0"}],["circle",{cx:"12",cy:"12",r:"3",key:"1v7zrd"}]]);const $m=Nl("Globe",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20",key:"13o1zl"}],["path",{d:"M2 12h20",key:"9i4pu4"}]]);const kd=Nl("LoaderCircle",[["path",{d:"M21 12a9 9 0 1 1-6.219-8.56",key:"13zald"}]]);const Fm=Nl("Lock",[["rect",{width:"18",height:"11",x:"3",y:"11",rx:"2",ry:"2",key:"1w4ew1"}],["path",{d:"M7 11V7a5 5 0 0 1 10 0v4",key:"fwvmzm"}]]);const Im=Nl("LogIn",[["path",{d:"M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4",key:"u53s6r"}],["polyline",{points:"10 17 15 12 10 7",key:"1ail0h"}],["line",{x1:"15",x2:"3",y1:"12",y2:"12",key:"v6grx8"}]]);const Pm=Nl("RotateCw",[["path",{d:"M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8",key:"1p45f6"}],["path",{d:"M21 3v5h-5",key:"1q7to0"}]]);const th=Nl("User",[["path",{d:"M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2",key:"975kel"}],["circle",{cx:"12",cy:"7",r:"4",key:"17ys0d"}]]);const lh=Nl("Waypoints",[["circle",{cx:"12",cy:"4.5",r:"2.5",key:"r5ysbb"}],["path",{d:"m10.2 6.3-3.9 3.9",key:"1nzqf6"}],["circle",{cx:"4.5",cy:"12",r:"2.5",key:"jydg6v"}],["path",{d:"M7 12h10",key:"b7w52i"}],["circle",{cx:"19.5",cy:"12",r:"2.5",key:"1piiel"}],["path",{d:"m13.8 17.7 3.9-3.9",key:"1wyg1y"}],["circle",{cx:"12",cy:"19.5",r:"2.5",key:"13o1pw"}]]);const eh=Nl("X",[["path",{d:"M18 6 6 18",key:"1bl5f8"}],["path",{d:"m6 6 12 12",key:"d8bk6v"}]]);function t0(){return globalThis.__DATA__??{}}function l0(r){var v,S,f="";if(typeof r=="string"||typeof r=="number")f+=r;else if(typeof r=="object")if(Array.isArray(r)){var _=r.length;for(v=0;v<_;v++)r[v]&&(S=l0(r[v]))&&(f&&(f+=" "),f+=S)}else for(S in r)r[S]&&(f&&(f+=" "),f+=S);return f}function ah(){for(var r,v,S=0,f="",_=arguments.length;S<_;S++)(r=arguments[S])&&(v=l0(r))&&(f&&(f+=" "),f+=v);return f}const Hf="-",uh=r=>{const v=ih(r),{conflictingClassGroups:S,conflictingClassGroupModifiers:f}=r;return{getClassGroupId:D=>{const U=D.split(Hf);return U[0]===""&&U.length!==1&&U.shift(),e0(U,v)||nh(D)},getConflictingClassGroupIds:(D,U)=>{const N=S[D]||[];return U&&f[D]?[...N,...f[D]]:N}}},e0=(r,v)=>{if(r.length===0)return v.classGroupId;const S=r[0],f=v.nextPart.get(S),_=f?e0(r.slice(1),f):void 0;if(_)return _;if(v.validators.length===0)return;const O=r.join(Hf);return v.validators.find(({validator:D})=>D(O))?.classGroupId},Wd=/^\[(.+)\]$/,nh=r=>{if(Wd.test(r)){const v=Wd.exec(r)[1],S=v?.substring(0,v.indexOf(":"));if(S)return"arbitrary.."+S}},ih=r=>{const{theme:v,prefix:S}=r,f={nextPart:new Map,validators:[]};return fh(Object.entries(r.classGroups),S).forEach(([O,D])=>{Df(D,f,O,v)}),f},Df=(r,v,S,f)=>{r.forEach(_=>{if(typeof _=="string"){const O=_===""?v:$d(v,_);O.classGroupId=S;return}if(typeof _=="function"){if(ch(_)){Df(_(f),v,S,f);return}v.validators.push({validator:_,classGroupId:S});return}Object.entries(_).forEach(([O,D])=>{Df(D,$d(v,O),S,f)})})},$d=(r,v)=>{let S=r;return v.split(Hf).forEach(f=>{S.nextPart.has(f)||S.nextPart.set(f,{nextPart:new Map,validators:[]}),S=S.nextPart.get(f)}),S},ch=r=>r.isThemeGetter,fh=(r,v)=>v?r.map(([S,f])=>{const _=f.map(O=>typeof O=="string"?v+O:typeof O=="object"?Object.fromEntries(Object.entries(O).map(([D,U])=>[v+D,U])):O);return[S,_]}):r,rh=r=>{if(r<1)return{get:()=>{},set:()=>{}};let v=0,S=new Map,f=new Map;const _=(O,D)=>{S.set(O,D),v++,v>r&&(v=0,f=S,S=new Map)};return{get(O){let D=S.get(O);if(D!==void 0)return D;if((D=f.get(O))!==void 0)return _(O,D),D},set(O,D){S.has(O)?S.set(O,D):_(O,D)}}},a0="!",oh=r=>{const{separator:v,experimentalParseClassName:S}=r,f=v.length===1,_=v[0],O=v.length,D=U=>{const N=[];let p=0,R=0,H;for(let Q=0;QR?H-R:void 0;return{modifiers:N,hasImportantModifier:st,baseClassName:ct,maybePostfixModifierPosition:G}};return S?U=>S({className:U,parseClassName:D}):D},sh=r=>{if(r.length<=1)return r;const v=[];let S=[];return r.forEach(f=>{f[0]==="["?(v.push(...S.sort(),f),S=[]):S.push(f)}),v.push(...S.sort()),v},dh=r=>({cache:rh(r.cacheSize),parseClassName:oh(r),...uh(r)}),yh=/\s+/,mh=(r,v)=>{const{parseClassName:S,getClassGroupId:f,getConflictingClassGroupIds:_}=v,O=[],D=r.trim().split(yh);let U="";for(let N=D.length-1;N>=0;N-=1){const p=D[N],{modifiers:R,hasImportantModifier:H,baseClassName:V,maybePostfixModifierPosition:st}=S(p);let ct=!!st,G=f(ct?V.substring(0,st):V);if(!G){if(!ct){U=p+(U.length>0?" "+U:U);continue}if(G=f(V),!G){U=p+(U.length>0?" "+U:U);continue}ct=!1}const Q=sh(R).join(":"),L=H?Q+a0:Q,gt=L+G;if(O.includes(gt))continue;O.push(gt);const zt=_(G,ct);for(let _t=0;_t0?" "+U:U)}return U};function hh(){let r=0,v,S,f="";for(;r{if(typeof r=="string")return r;let v,S="";for(let f=0;fH(R),r());return S=dh(p),f=S.cache.get,_=S.cache.set,O=U,U(N)}function U(N){const p=f(N);if(p)return p;const R=mh(N,S);return _(N,R),R}return function(){return O(hh.apply(null,arguments))}}const At=r=>{const v=S=>S[r]||[];return v.isThemeGetter=!0,v},n0=/^\[(?:([a-z-]+):)?(.+)\]$/i,vh=/^\d+\/\d+$/,bh=new Set(["px","full","screen"]),ph=/^(\d+(\.\d+)?)?(xs|sm|md|lg|xl)$/,Sh=/\d+(%|px|r?em|[sdl]?v([hwib]|min|max)|pt|pc|in|cm|mm|cap|ch|ex|r?lh|cq(w|h|i|b|min|max))|\b(calc|min|max|clamp)\(.+\)|^0$/,xh=/^(rgba?|hsla?|hwb|(ok)?(lab|lch)|color-mix)\(.+\)$/,zh=/^(inset_)?-?((\d+)?\.?(\d+)[a-z]+|0)_-?((\d+)?\.?(\d+)[a-z]+|0)/,Th=/^(url|image|image-set|cross-fade|element|(repeating-)?(linear|radial|conic)-gradient)\(.+\)$/,ae=r=>Ha(r)||bh.has(r)||vh.test(r),Ne=r=>Ba(r,"length",Uh),Ha=r=>!!r&&!Number.isNaN(Number(r)),Mf=r=>Ba(r,"number",Ha),Cu=r=>!!r&&Number.isInteger(Number(r)),Eh=r=>r.endsWith("%")&&Ha(r.slice(0,-1)),F=r=>n0.test(r),De=r=>ph.test(r),Ah=new Set(["length","size","percentage"]),Mh=r=>Ba(r,Ah,i0),_h=r=>Ba(r,"position",i0),Oh=new Set(["image","url"]),Nh=r=>Ba(r,Oh,jh),Dh=r=>Ba(r,"",Ch),ju=()=>!0,Ba=(r,v,S)=>{const f=n0.exec(r);return f?f[1]?typeof v=="string"?f[1]===v:v.has(f[1]):S(f[2]):!1},Uh=r=>Sh.test(r)&&!xh.test(r),i0=()=>!1,Ch=r=>zh.test(r),jh=r=>Th.test(r),Rh=()=>{const r=At("colors"),v=At("spacing"),S=At("blur"),f=At("brightness"),_=At("borderColor"),O=At("borderRadius"),D=At("borderSpacing"),U=At("borderWidth"),N=At("contrast"),p=At("grayscale"),R=At("hueRotate"),H=At("invert"),V=At("gap"),st=At("gradientColorStops"),ct=At("gradientColorStopPositions"),G=At("inset"),Q=At("margin"),L=At("opacity"),gt=At("padding"),zt=At("saturate"),_t=At("scale"),it=At("sepia"),Ot=At("skew"),J=At("space"),Rt=At("translate"),It=()=>["auto","contain","none"],jl=()=>["auto","hidden","clip","visible","scroll"],Pt=()=>["auto",F,v],I=()=>[F,v],Rl=()=>["",ae,Ne],tl=()=>["auto",Ha,F],ll=()=>["bottom","center","left","left-bottom","left-top","right","right-bottom","right-top","top"],x=()=>["solid","dashed","dotted","double","none"],C=()=>["normal","multiply","screen","overlay","darken","lighten","color-dodge","color-burn","hard-light","soft-light","difference","exclusion","hue","saturation","color","luminosity"],Z=()=>["start","end","center","between","around","evenly","stretch"],nt=()=>["","0",F],dt=()=>["auto","avoid","all","avoid-page","page","left","right","column"],s=()=>[Ha,F];return{cacheSize:500,separator:":",theme:{colors:[ju],spacing:[ae,Ne],blur:["none","",De,F],brightness:s(),borderColor:[r],borderRadius:["none","","full",De,F],borderSpacing:I(),borderWidth:Rl(),contrast:s(),grayscale:nt(),hueRotate:s(),invert:nt(),gap:I(),gradientColorStops:[r],gradientColorStopPositions:[Eh,Ne],inset:Pt(),margin:Pt(),opacity:s(),padding:I(),saturate:s(),scale:s(),sepia:nt(),skew:s(),space:I(),translate:I()},classGroups:{aspect:[{aspect:["auto","square","video",F]}],container:["container"],columns:[{columns:[De]}],"break-after":[{"break-after":dt()}],"break-before":[{"break-before":dt()}],"break-inside":[{"break-inside":["auto","avoid","avoid-page","avoid-column"]}],"box-decoration":[{"box-decoration":["slice","clone"]}],box:[{box:["border","content"]}],display:["block","inline-block","inline","flex","inline-flex","table","inline-table","table-caption","table-cell","table-column","table-column-group","table-footer-group","table-header-group","table-row-group","table-row","flow-root","grid","inline-grid","contents","list-item","hidden"],float:[{float:["right","left","none","start","end"]}],clear:[{clear:["left","right","both","none","start","end"]}],isolation:["isolate","isolation-auto"],"object-fit":[{object:["contain","cover","fill","none","scale-down"]}],"object-position":[{object:[...ll(),F]}],overflow:[{overflow:jl()}],"overflow-x":[{"overflow-x":jl()}],"overflow-y":[{"overflow-y":jl()}],overscroll:[{overscroll:It()}],"overscroll-x":[{"overscroll-x":It()}],"overscroll-y":[{"overscroll-y":It()}],position:["static","fixed","absolute","relative","sticky"],inset:[{inset:[G]}],"inset-x":[{"inset-x":[G]}],"inset-y":[{"inset-y":[G]}],start:[{start:[G]}],end:[{end:[G]}],top:[{top:[G]}],right:[{right:[G]}],bottom:[{bottom:[G]}],left:[{left:[G]}],visibility:["visible","invisible","collapse"],z:[{z:["auto",Cu,F]}],basis:[{basis:Pt()}],"flex-direction":[{flex:["row","row-reverse","col","col-reverse"]}],"flex-wrap":[{flex:["wrap","wrap-reverse","nowrap"]}],flex:[{flex:["1","auto","initial","none",F]}],grow:[{grow:nt()}],shrink:[{shrink:nt()}],order:[{order:["first","last","none",Cu,F]}],"grid-cols":[{"grid-cols":[ju]}],"col-start-end":[{col:["auto",{span:["full",Cu,F]},F]}],"col-start":[{"col-start":tl()}],"col-end":[{"col-end":tl()}],"grid-rows":[{"grid-rows":[ju]}],"row-start-end":[{row:["auto",{span:[Cu,F]},F]}],"row-start":[{"row-start":tl()}],"row-end":[{"row-end":tl()}],"grid-flow":[{"grid-flow":["row","col","dense","row-dense","col-dense"]}],"auto-cols":[{"auto-cols":["auto","min","max","fr",F]}],"auto-rows":[{"auto-rows":["auto","min","max","fr",F]}],gap:[{gap:[V]}],"gap-x":[{"gap-x":[V]}],"gap-y":[{"gap-y":[V]}],"justify-content":[{justify:["normal",...Z()]}],"justify-items":[{"justify-items":["start","end","center","stretch"]}],"justify-self":[{"justify-self":["auto","start","end","center","stretch"]}],"align-content":[{content:["normal",...Z(),"baseline"]}],"align-items":[{items:["start","end","center","baseline","stretch"]}],"align-self":[{self:["auto","start","end","center","stretch","baseline"]}],"place-content":[{"place-content":[...Z(),"baseline"]}],"place-items":[{"place-items":["start","end","center","baseline","stretch"]}],"place-self":[{"place-self":["auto","start","end","center","stretch"]}],p:[{p:[gt]}],px:[{px:[gt]}],py:[{py:[gt]}],ps:[{ps:[gt]}],pe:[{pe:[gt]}],pt:[{pt:[gt]}],pr:[{pr:[gt]}],pb:[{pb:[gt]}],pl:[{pl:[gt]}],m:[{m:[Q]}],mx:[{mx:[Q]}],my:[{my:[Q]}],ms:[{ms:[Q]}],me:[{me:[Q]}],mt:[{mt:[Q]}],mr:[{mr:[Q]}],mb:[{mb:[Q]}],ml:[{ml:[Q]}],"space-x":[{"space-x":[J]}],"space-x-reverse":["space-x-reverse"],"space-y":[{"space-y":[J]}],"space-y-reverse":["space-y-reverse"],w:[{w:["auto","min","max","fit","svw","lvw","dvw",F,v]}],"min-w":[{"min-w":[F,v,"min","max","fit"]}],"max-w":[{"max-w":[F,v,"none","full","min","max","fit","prose",{screen:[De]},De]}],h:[{h:[F,v,"auto","min","max","fit","svh","lvh","dvh"]}],"min-h":[{"min-h":[F,v,"min","max","fit","svh","lvh","dvh"]}],"max-h":[{"max-h":[F,v,"min","max","fit","svh","lvh","dvh"]}],size:[{size:[F,v,"auto","min","max","fit"]}],"font-size":[{text:["base",De,Ne]}],"font-smoothing":["antialiased","subpixel-antialiased"],"font-style":["italic","not-italic"],"font-weight":[{font:["thin","extralight","light","normal","medium","semibold","bold","extrabold","black",Mf]}],"font-family":[{font:[ju]}],"fvn-normal":["normal-nums"],"fvn-ordinal":["ordinal"],"fvn-slashed-zero":["slashed-zero"],"fvn-figure":["lining-nums","oldstyle-nums"],"fvn-spacing":["proportional-nums","tabular-nums"],"fvn-fraction":["diagonal-fractions","stacked-fractions"],tracking:[{tracking:["tighter","tight","normal","wide","wider","widest",F]}],"line-clamp":[{"line-clamp":["none",Ha,Mf]}],leading:[{leading:["none","tight","snug","normal","relaxed","loose",ae,F]}],"list-image":[{"list-image":["none",F]}],"list-style-type":[{list:["none","disc","decimal",F]}],"list-style-position":[{list:["inside","outside"]}],"placeholder-color":[{placeholder:[r]}],"placeholder-opacity":[{"placeholder-opacity":[L]}],"text-alignment":[{text:["left","center","right","justify","start","end"]}],"text-color":[{text:[r]}],"text-opacity":[{"text-opacity":[L]}],"text-decoration":["underline","overline","line-through","no-underline"],"text-decoration-style":[{decoration:[...x(),"wavy"]}],"text-decoration-thickness":[{decoration:["auto","from-font",ae,Ne]}],"underline-offset":[{"underline-offset":["auto",ae,F]}],"text-decoration-color":[{decoration:[r]}],"text-transform":["uppercase","lowercase","capitalize","normal-case"],"text-overflow":["truncate","text-ellipsis","text-clip"],"text-wrap":[{text:["wrap","nowrap","balance","pretty"]}],indent:[{indent:I()}],"vertical-align":[{align:["baseline","top","middle","bottom","text-top","text-bottom","sub","super",F]}],whitespace:[{whitespace:["normal","nowrap","pre","pre-line","pre-wrap","break-spaces"]}],break:[{break:["normal","words","all","keep"]}],hyphens:[{hyphens:["none","manual","auto"]}],content:[{content:["none",F]}],"bg-attachment":[{bg:["fixed","local","scroll"]}],"bg-clip":[{"bg-clip":["border","padding","content","text"]}],"bg-opacity":[{"bg-opacity":[L]}],"bg-origin":[{"bg-origin":["border","padding","content"]}],"bg-position":[{bg:[...ll(),_h]}],"bg-repeat":[{bg:["no-repeat",{repeat:["","x","y","round","space"]}]}],"bg-size":[{bg:["auto","cover","contain",Mh]}],"bg-image":[{bg:["none",{"gradient-to":["t","tr","r","br","b","bl","l","tl"]},Nh]}],"bg-color":[{bg:[r]}],"gradient-from-pos":[{from:[ct]}],"gradient-via-pos":[{via:[ct]}],"gradient-to-pos":[{to:[ct]}],"gradient-from":[{from:[st]}],"gradient-via":[{via:[st]}],"gradient-to":[{to:[st]}],rounded:[{rounded:[O]}],"rounded-s":[{"rounded-s":[O]}],"rounded-e":[{"rounded-e":[O]}],"rounded-t":[{"rounded-t":[O]}],"rounded-r":[{"rounded-r":[O]}],"rounded-b":[{"rounded-b":[O]}],"rounded-l":[{"rounded-l":[O]}],"rounded-ss":[{"rounded-ss":[O]}],"rounded-se":[{"rounded-se":[O]}],"rounded-ee":[{"rounded-ee":[O]}],"rounded-es":[{"rounded-es":[O]}],"rounded-tl":[{"rounded-tl":[O]}],"rounded-tr":[{"rounded-tr":[O]}],"rounded-br":[{"rounded-br":[O]}],"rounded-bl":[{"rounded-bl":[O]}],"border-w":[{border:[U]}],"border-w-x":[{"border-x":[U]}],"border-w-y":[{"border-y":[U]}],"border-w-s":[{"border-s":[U]}],"border-w-e":[{"border-e":[U]}],"border-w-t":[{"border-t":[U]}],"border-w-r":[{"border-r":[U]}],"border-w-b":[{"border-b":[U]}],"border-w-l":[{"border-l":[U]}],"border-opacity":[{"border-opacity":[L]}],"border-style":[{border:[...x(),"hidden"]}],"divide-x":[{"divide-x":[U]}],"divide-x-reverse":["divide-x-reverse"],"divide-y":[{"divide-y":[U]}],"divide-y-reverse":["divide-y-reverse"],"divide-opacity":[{"divide-opacity":[L]}],"divide-style":[{divide:x()}],"border-color":[{border:[_]}],"border-color-x":[{"border-x":[_]}],"border-color-y":[{"border-y":[_]}],"border-color-s":[{"border-s":[_]}],"border-color-e":[{"border-e":[_]}],"border-color-t":[{"border-t":[_]}],"border-color-r":[{"border-r":[_]}],"border-color-b":[{"border-b":[_]}],"border-color-l":[{"border-l":[_]}],"divide-color":[{divide:[_]}],"outline-style":[{outline:["",...x()]}],"outline-offset":[{"outline-offset":[ae,F]}],"outline-w":[{outline:[ae,Ne]}],"outline-color":[{outline:[r]}],"ring-w":[{ring:Rl()}],"ring-w-inset":["ring-inset"],"ring-color":[{ring:[r]}],"ring-opacity":[{"ring-opacity":[L]}],"ring-offset-w":[{"ring-offset":[ae,Ne]}],"ring-offset-color":[{"ring-offset":[r]}],shadow:[{shadow:["","inner","none",De,Dh]}],"shadow-color":[{shadow:[ju]}],opacity:[{opacity:[L]}],"mix-blend":[{"mix-blend":[...C(),"plus-lighter","plus-darker"]}],"bg-blend":[{"bg-blend":C()}],filter:[{filter:["","none"]}],blur:[{blur:[S]}],brightness:[{brightness:[f]}],contrast:[{contrast:[N]}],"drop-shadow":[{"drop-shadow":["","none",De,F]}],grayscale:[{grayscale:[p]}],"hue-rotate":[{"hue-rotate":[R]}],invert:[{invert:[H]}],saturate:[{saturate:[zt]}],sepia:[{sepia:[it]}],"backdrop-filter":[{"backdrop-filter":["","none"]}],"backdrop-blur":[{"backdrop-blur":[S]}],"backdrop-brightness":[{"backdrop-brightness":[f]}],"backdrop-contrast":[{"backdrop-contrast":[N]}],"backdrop-grayscale":[{"backdrop-grayscale":[p]}],"backdrop-hue-rotate":[{"backdrop-hue-rotate":[R]}],"backdrop-invert":[{"backdrop-invert":[H]}],"backdrop-opacity":[{"backdrop-opacity":[L]}],"backdrop-saturate":[{"backdrop-saturate":[zt]}],"backdrop-sepia":[{"backdrop-sepia":[it]}],"border-collapse":[{border:["collapse","separate"]}],"border-spacing":[{"border-spacing":[D]}],"border-spacing-x":[{"border-spacing-x":[D]}],"border-spacing-y":[{"border-spacing-y":[D]}],"table-layout":[{table:["auto","fixed"]}],caption:[{caption:["top","bottom"]}],transition:[{transition:["none","all","","colors","opacity","shadow","transform",F]}],duration:[{duration:s()}],ease:[{ease:["linear","in","out","in-out",F]}],delay:[{delay:s()}],animate:[{animate:["none","spin","ping","pulse","bounce",F]}],transform:[{transform:["","gpu","none"]}],scale:[{scale:[_t]}],"scale-x":[{"scale-x":[_t]}],"scale-y":[{"scale-y":[_t]}],rotate:[{rotate:[Cu,F]}],"translate-x":[{"translate-x":[Rt]}],"translate-y":[{"translate-y":[Rt]}],"skew-x":[{"skew-x":[Ot]}],"skew-y":[{"skew-y":[Ot]}],"transform-origin":[{origin:["center","top","top-right","right","bottom-right","bottom","bottom-left","left","top-left",F]}],accent:[{accent:["auto",r]}],appearance:[{appearance:["none","auto"]}],cursor:[{cursor:["auto","default","pointer","wait","text","move","help","not-allowed","none","context-menu","progress","cell","crosshair","vertical-text","alias","copy","no-drop","grab","grabbing","all-scroll","col-resize","row-resize","n-resize","e-resize","s-resize","w-resize","ne-resize","nw-resize","se-resize","sw-resize","ew-resize","ns-resize","nesw-resize","nwse-resize","zoom-in","zoom-out",F]}],"caret-color":[{caret:[r]}],"pointer-events":[{"pointer-events":["none","auto"]}],resize:[{resize:["none","y","x",""]}],"scroll-behavior":[{scroll:["auto","smooth"]}],"scroll-m":[{"scroll-m":I()}],"scroll-mx":[{"scroll-mx":I()}],"scroll-my":[{"scroll-my":I()}],"scroll-ms":[{"scroll-ms":I()}],"scroll-me":[{"scroll-me":I()}],"scroll-mt":[{"scroll-mt":I()}],"scroll-mr":[{"scroll-mr":I()}],"scroll-mb":[{"scroll-mb":I()}],"scroll-ml":[{"scroll-ml":I()}],"scroll-p":[{"scroll-p":I()}],"scroll-px":[{"scroll-px":I()}],"scroll-py":[{"scroll-py":I()}],"scroll-ps":[{"scroll-ps":I()}],"scroll-pe":[{"scroll-pe":I()}],"scroll-pt":[{"scroll-pt":I()}],"scroll-pr":[{"scroll-pr":I()}],"scroll-pb":[{"scroll-pb":I()}],"scroll-pl":[{"scroll-pl":I()}],"snap-align":[{snap:["start","end","center","align-none"]}],"snap-stop":[{snap:["normal","always"]}],"snap-type":[{snap:["none","x","y","both"]}],"snap-strictness":[{snap:["mandatory","proximity"]}],touch:[{touch:["auto","none","manipulation"]}],"touch-x":[{"touch-pan":["x","left","right"]}],"touch-y":[{"touch-pan":["y","up","down"]}],"touch-pz":["touch-pinch-zoom"],select:[{select:["none","text","all","auto"]}],"will-change":[{"will-change":["auto","scroll","contents","transform",F]}],fill:[{fill:[r,"none"]}],"stroke-w":[{stroke:[ae,Ne,Mf]}],stroke:[{stroke:[r,"none"]}],sr:["sr-only","not-sr-only"],"forced-color-adjust":[{"forced-color-adjust":["auto","none"]}]},conflictingClassGroups:{overflow:["overflow-x","overflow-y"],overscroll:["overscroll-x","overscroll-y"],inset:["inset-x","inset-y","start","end","top","right","bottom","left"],"inset-x":["right","left"],"inset-y":["top","bottom"],flex:["basis","grow","shrink"],gap:["gap-x","gap-y"],p:["px","py","ps","pe","pt","pr","pb","pl"],px:["pr","pl"],py:["pt","pb"],m:["mx","my","ms","me","mt","mr","mb","ml"],mx:["mr","ml"],my:["mt","mb"],size:["w","h"],"font-size":["leading"],"fvn-normal":["fvn-ordinal","fvn-slashed-zero","fvn-figure","fvn-spacing","fvn-fraction"],"fvn-ordinal":["fvn-normal"],"fvn-slashed-zero":["fvn-normal"],"fvn-figure":["fvn-normal"],"fvn-spacing":["fvn-normal"],"fvn-fraction":["fvn-normal"],"line-clamp":["display","overflow"],rounded:["rounded-s","rounded-e","rounded-t","rounded-r","rounded-b","rounded-l","rounded-ss","rounded-se","rounded-ee","rounded-es","rounded-tl","rounded-tr","rounded-br","rounded-bl"],"rounded-s":["rounded-ss","rounded-es"],"rounded-e":["rounded-se","rounded-ee"],"rounded-t":["rounded-tl","rounded-tr"],"rounded-r":["rounded-tr","rounded-br"],"rounded-b":["rounded-br","rounded-bl"],"rounded-l":["rounded-tl","rounded-bl"],"border-spacing":["border-spacing-x","border-spacing-y"],"border-w":["border-w-s","border-w-e","border-w-t","border-w-r","border-w-b","border-w-l"],"border-w-x":["border-w-r","border-w-l"],"border-w-y":["border-w-t","border-w-b"],"border-color":["border-color-s","border-color-e","border-color-t","border-color-r","border-color-b","border-color-l"],"border-color-x":["border-color-r","border-color-l"],"border-color-y":["border-color-t","border-color-b"],"scroll-m":["scroll-mx","scroll-my","scroll-ms","scroll-me","scroll-mt","scroll-mr","scroll-mb","scroll-ml"],"scroll-mx":["scroll-mr","scroll-ml"],"scroll-my":["scroll-mt","scroll-mb"],"scroll-p":["scroll-px","scroll-py","scroll-ps","scroll-pe","scroll-pt","scroll-pr","scroll-pb","scroll-pl"],"scroll-px":["scroll-pr","scroll-pl"],"scroll-py":["scroll-pt","scroll-pb"],touch:["touch-x","touch-y","touch-pz"],"touch-x":["touch"],"touch-y":["touch"],"touch-pz":["touch"]},conflictingClassGroupModifiers:{"font-size":["leading"]}}},Hh=gh(Rh);function Zt(...r){return Hh(ah(r))}const Bh=["relative cursor-pointer","text-sm focus:z-10 focus:ring-2 font-medium focus:outline-none whitespace-nowrap shadow-sm","inline-flex gap-2 items-center justify-center transition-colors focus:ring-offset-1","disabled:opacity-40 disabled:cursor-not-allowed disabled:text-nb-gray-300 ring-offset-neutral-950/50"],qh={default:["bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900","dark:focus:ring-zinc-800/50 dark:bg-nb-gray dark:text-gray-400 dark:border-gray-700/30 dark:hover:text-white dark:hover:bg-zinc-800/50"],primary:["dark:focus:ring-netbird-600/50 dark:ring-offset-neutral-950/50 enabled:dark:bg-netbird disabled:dark:bg-nb-gray-910 dark:text-gray-100 enabled:dark:hover:text-white enabled:dark:hover:bg-netbird-500/80","enabled:bg-netbird enabled:text-white enabled:focus:ring-netbird-400/50 enabled:hover:bg-netbird-500"],secondary:["bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900","dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20","dark:bg-nb-gray-920 dark:text-gray-400 dark:border-gray-700/40 dark:hover:text-white dark:hover:bg-nb-gray-910"],secondaryLighter:["bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900","dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20","dark:bg-nb-gray-900/70 dark:text-gray-400 dark:border-gray-700/70 dark:hover:text-white dark:hover:bg-nb-gray-800/60"],input:["bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-neutral-200 text-gray-900","dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20","dark:bg-nb-gray-900 dark:text-gray-400 dark:border-nb-gray-700 dark:hover:bg-nb-gray-900/80"],dropdown:["bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-neutral-200 text-gray-900","dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20","dark:bg-nb-gray-900/40 dark:text-gray-400 dark:border-nb-gray-900 dark:hover:bg-nb-gray-900/50"],dotted:["bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900 border-dashed","dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20","dark:bg-nb-gray-900/30 dark:text-gray-400 dark:border-gray-500/40 dark:hover:text-white dark:hover:bg-zinc-800/50"],tertiary:["bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900","dark:focus:ring-zinc-800/50 dark:bg-white dark:text-gray-800 dark:border-gray-700/40 dark:hover:bg-neutral-200 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300"],white:["focus:ring-white/50 bg-white text-gray-800 border-white outline-none hover:bg-neutral-200 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300","disabled:dark:bg-nb-gray-900 disabled:dark:text-nb-gray-300 disabled:dark:border-nb-gray-900"],outline:["bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900","dark:focus:ring-zinc-800/50 dark:bg-transparent dark:text-netbird dark:border-netbird dark:hover:bg-nb-gray-900/30"],"danger-outline":["enabled:dark:focus:ring-red-800/20 enabled:dark:focus:bg-red-950/40 enabled:hover:dark:bg-red-950/50 enabled:dark:hover:border-red-800/50 dark:bg-transparent dark:text-red-500"],"danger-text":["dark:bg-transparent dark:text-red-500 dark:hover:text-red-600 dark:border-transparent !px-0 !shadow-none !py-0 focus:ring-red-500/30 dark:ring-offset-neutral-950/50"],"default-outline":["dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20","dark:bg-transparent dark:text-nb-gray-400 dark:border-transparent dark:hover:text-white dark:hover:bg-nb-gray-900/30 dark:hover:border-nb-gray-800/50","data-[state=open]:dark:text-white data-[state=open]:dark:bg-nb-gray-900/30 data-[state=open]:dark:border-nb-gray-800/50"],danger:["dark:focus:ring-red-700/20 dark:focus:bg-red-700 hover:dark:bg-red-700 dark:hover:border-red-800/50 dark:bg-red-600 dark:text-red-100"]},Yh={xs:"text-xs py-2 px-4",xs2:"text-[0.78rem] py-2 px-4",sm:"text-sm py-2.5 px-4",md:"text-sm py-2.5 px-4",lg:"text-base py-2.5 px-4"},Gh={0:"border",1:"border border-transparent",2:"border border-t-0 border-b-0"},Ru=xt.forwardRef(({variant:r="default",rounded:v=!0,border:S=1,size:f="md",stopPropagation:_=!0,className:O,onClick:D,children:U,...N},p)=>A.jsx("button",{type:"button",...N,ref:p,className:Zt(Bh,qh[r],Yh[f],Gh[S?1:0],v&&"rounded-md",O),onClick:R=>{_&&R.stopPropagation(),D?.(R)},children:U}));Ru.displayName="Button";const Xh={default:["bg-nb-gray-900 placeholder:text-neutral-400/70 border-nb-gray-700","ring-offset-neutral-950/50 focus-visible:ring-neutral-500/20"],darker:["bg-nb-gray-920 placeholder:text-neutral-400/70 border-nb-gray-800","ring-offset-neutral-950/50 focus-visible:ring-neutral-500/20"],error:["bg-nb-gray-900 placeholder:text-neutral-400/70 border-red-500 text-red-500","ring-offset-red-500/10 focus-visible:ring-red-500/10"]},Qh={default:"bg-nb-gray-900 border-nb-gray-700 text-nb-gray-300",error:"bg-nb-gray-900 border-red-500 text-nb-gray-300 text-red-500"},c0=xt.forwardRef(({className:r,type:v,customSuffix:S,customPrefix:f,icon:_,maxWidthClass:O="",error:D,variant:U="default",prefixClassName:N,showPasswordToggle:p=!1,...R},H)=>{const[V,st]=xt.useState(!1),ct=v==="password",G=ct&&V?"text":v,L=(ct&&p?A.jsx("button",{type:"button",onClick:()=>st(!V),className:"hover:text-white transition-all","aria-label":"Toggle password visibility",children:V?A.jsx(km,{size:18}):A.jsx(Wm,{size:18})}):null)||S,gt=D?"error":U;return A.jsxs(A.Fragment,{children:[A.jsxs("div",{className:Zt("flex relative h-[42px]",O),children:[f&&A.jsx("div",{className:Zt(Qh[D?"error":"default"],"flex h-[42px] w-auto rounded-l-md px-3 py-2 text-sm","border items-center whitespace-nowrap",R.disabled&&"opacity-40",N),children:f}),A.jsx("div",{className:Zt("absolute left-0 top-0 h-full flex items-center text-xs text-nb-gray-300 pl-3 leading-[0]",R.disabled&&"opacity-40"),children:_}),A.jsx("input",{type:G,ref:H,...R,className:Zt(Xh[gt],"flex h-[42px] w-full rounded-md px-3 py-2 text-sm","file:bg-transparent file:text-sm file:font-medium file:border-0","focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2","disabled:cursor-not-allowed disabled:opacity-40","border",f&&"!border-l-0 !rounded-l-none",L&&"!pr-16",_&&"!pl-10",r)}),A.jsx("div",{className:Zt("absolute right-0 top-0 h-full flex items-center text-xs text-nb-gray-300 pr-4 leading-[0] select-none",R.disabled&&"opacity-30"),children:L})]}),D&&A.jsx("p",{className:"text-xs text-red-500 mt-2",children:D})]})});c0.displayName="Input";const Zh=xt.forwardRef(function({value:v,onChange:S,length:f=6,disabled:_=!1,className:O,autoFocus:D=!1},U){const N=xt.useRef([]);xt.useImperativeHandle(U,()=>({focus:()=>{N.current[0]?.focus()}}));const p=v.split("").concat(new Array(f).fill("")).slice(0,f),R=Array.from({length:f},(G,Q)=>`pin-${Q}`),H=(G,Q)=>{if(!/^\d*$/.test(Q))return;const L=[...p];L[G]=Q.slice(-1);const gt=L.join("").replaceAll(/\s/g,"");S(gt),Q&&G{Q.key==="Backspace"&&!p[G]&&G>0&&N.current[G-1]?.focus(),Q.key==="ArrowLeft"&&G>0&&N.current[G-1]?.focus(),Q.key==="ArrowRight"&&G{G.preventDefault();const Q=G.clipboardData.getData("text").replaceAll(/\D/g,"").slice(0,f);S(Q);const L=Math.min(Q.length,f-1);N.current[L]?.focus()},ct=G=>{G.target.select()};return A.jsx("div",{className:Zt("flex gap-2 w-full min-w-0",O),children:p.map((G,Q)=>A.jsx("input",{id:R[Q],ref:L=>{N.current[Q]=L},type:"text",inputMode:"numeric",maxLength:1,value:G,onChange:L=>H(Q,L.target.value),onKeyDown:L=>V(Q,L),onPaste:st,onFocus:ct,disabled:_,autoFocus:D&&Q===0,className:Zt("flex-1 min-w-0 h-[42px] text-center text-sm rounded-md","dark:bg-nb-gray-900 border dark:border-nb-gray-700","dark:placeholder:text-neutral-400/70","focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2","ring-offset-neutral-200/20 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20","disabled:cursor-not-allowed disabled:opacity-40")},R[Q]))})}),f0=xt.createContext({value:"",onChange:()=>{}}),r0=()=>xt.useContext(f0);function $e({value:r,defaultValue:v,onChange:S,children:f}){const[_,O]=xt.useState(v??""),D=r??_,U=xt.useCallback(p=>{r===void 0&&O(p),S?.(p)},[r,S]),N=xt.useMemo(()=>({value:D,onChange:U}),[D,U]);return A.jsx(f0.Provider,{value:N,children:A.jsx("div",{children:typeof f=="function"?f({value:D,onChange:U}):f})})}function wh({children:r,className:v}){return A.jsx("div",{role:"tablist",className:Zt("bg-nb-gray-930/70 p-1.5 flex justify-center gap-1 border-nb-gray-900",v),children:r})}function Lh({children:r,value:v,disabled:S=!1,className:f,selected:_,onClick:O}){const D=r0(),U=_??v===D.value;let N="";U?N="bg-nb-gray-900 text-white":S||(N="text-nb-gray-400 hover:bg-nb-gray-900/50");const p=()=>{D.onChange(v),O?.()};return A.jsx("button",{role:"tab",type:"button",disabled:S,"aria-selected":U,onClick:p,className:Zt("px-4 py-2 text-sm rounded-md w-full transition-all cursor-pointer",S&&"opacity-30 cursor-not-allowed",N,f),children:A.jsx("div",{className:"flex items-center w-full justify-center gap-2",children:r})})}function Vh({children:r,value:v,className:S,visible:f}){const _=r0();return f??v===_.value?A.jsx("div",{role:"tabpanel",className:Zt("bg-nb-gray-930/70 px-4 pt-4 pb-5 rounded-b-md border border-t-0 border-nb-gray-900",S),children:r}):null}$e.List=wh;$e.Trigger=Lh;$e.Content=Vh;const Kh="/__netbird__/assets/netbird-full.svg",Jh="data:image/svg+xml,%3csvg%20width='31'%20height='23'%20viewBox='0%200%2031%2023'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M21.4631%200.523438C17.8173%200.857913%2016.0028%202.95675%2015.3171%204.01871L4.66406%2022.4734H17.5163L30.1929%200.523438H21.4631Z'%20fill='%23F68330'/%3e%3cpath%20d='M17.5265%2022.4737L0%203.88525C0%203.88525%2019.8177%20-1.44128%2021.7493%2015.1738L17.5265%2022.4737Z'%20fill='%23F68330'/%3e%3cpath%20d='M14.9236%204.70563L9.54688%2014.0208L17.5158%2022.4747L21.7385%2015.158C21.0696%209.44682%2018.2851%206.32784%2014.9236%204.69727'%20fill='%23F05252'/%3e%3c/svg%3e",ti={small:{desktop:14,mobile:20},default:{desktop:22,mobile:30},large:{desktop:24,mobile:40}},kh=({size:r="default",mobile:v=!0})=>A.jsxs(A.Fragment,{children:[A.jsx("img",{src:Kh,height:ti[r].desktop,style:{height:ti[r].desktop},alt:"NetBird Logo",className:Zt(v&&"hidden md:block","group-hover:opacity-80 transition-all")}),v&&A.jsx("img",{src:Jh,width:ti[r].mobile,style:{width:ti[r].mobile},alt:"NetBird Logo",className:Zt(v&&"md:hidden ml-4")})]});function Uf(){return A.jsxs("a",{href:"https://netbird.io?utm_source=netbird-proxy&utm_medium=web&utm_campaign=powered_by",target:"_blank",rel:"noopener noreferrer",className:"flex items-center justify-center mt-8 gap-2 group cursor-pointer",children:[A.jsx("span",{className:"text-sm text-nb-gray-400 font-light text-center group-hover:opacity-80 transition-all",children:"Powered by"}),A.jsx(kh,{size:"small",mobile:!1})]})}const Wh=({className:r})=>A.jsx("div",{className:Zt("h-full w-full absolute left-0 top-0 rounded-md overflow-hidden z-0 pointer-events-none",r),children:A.jsx("div",{className:"bg-linear-to-b from-nb-gray-900/10 via-transparent to-transparent w-full h-full rounded-md"})}),Fd=({children:r,className:v})=>A.jsxs("div",{className:Zt("px-6 sm:px-10 py-10 pt-8","bg-nb-gray-940 border border-nb-gray-910 rounded-lg relative",v),children:[A.jsx(Wh,{}),r]});function Cf({children:r,className:v}){return A.jsx("h1",{className:Zt("text-xl! text-center z-10 relative",v),children:r})}function jf({children:r,className:v}){return A.jsx("div",{className:Zt("text-sm text-nb-gray-300 font-light mt-2 block text-center z-10 relative",v),children:r})}const $h=()=>A.jsxs("div",{className:"flex items-center justify-center relative my-4",children:[A.jsx("span",{className:"bg-nb-gray-940 relative z-10 px-4 text-xs text-nb-gray-400 font-medium",children:"OR"}),A.jsx("span",{className:"h-px bg-nb-gray-900 w-full absolute z-0"})]}),Fh=({error:r})=>A.jsx("div",{className:"text-red-400 bg-red-800/20 border border-red-800/50 rounded-lg px-4 py-3 whitespace-break-spaces text-sm",children:r});function Id({className:r,htmlFor:v,...S}){return A.jsx("label",{htmlFor:v,className:Zt("text-sm font-medium tracking-wider leading-none","peer-disabled:cursor-not-allowed peer-disabled:opacity-70","mb-2.5 inline-block text-nb-gray-200","flex items-center gap-2 select-none",r),...S})}const _f=t0(),Ft=_f.methods&&Object.keys(_f.methods).length>0?_f.methods:{password:"password",pin:"pin",oidc:"/auth/oidc"};function Ih(){xt.useEffect(()=>{document.title="Authentication Required - NetBird Service"},[]);const[r,v]=xt.useState(null),[S,f]=xt.useState(null),[_,O]=xt.useState(""),[D,U]=xt.useState(""),N=xt.useRef(null),p=xt.useRef(null),[R,H]=xt.useState(Ft.password?"password":"pin"),V=(it,Ot)=>{v(Ot),f(null),it==="password"?(U(""),setTimeout(()=>N.current?.focus(),200)):(O(""),setTimeout(()=>p.current?.focus(),200))},st=(it,Ot)=>{v(null),f(it);const J=new FormData;it==="password"?J.append(Ft.password,Ot):J.append(Ft.pin,Ot),fetch(globalThis.location.href,{method:"POST",body:J,redirect:"manual"}).then(Rt=>{Rt.type==="opaqueredirect"||Rt.status===0?(f("redirect"),globalThis.location.reload()):V(it,"Authentication failed. Please try again.")}).catch(()=>{V(it,"An error occurred. Please try again.")})},ct=it=>{O(it),it.length===6&&st("pin",it)},G=_.length===6,Q=D.length>0,L=S!==null||R==="password"&&!Q||R==="pin"&&!G,gt=Ft.password||Ft.pin,zt=Ft.password&&Ft.pin,_t=R==="password"?"Sign in":"Submit";return S==="redirect"?A.jsxs("main",{className:"mt-20",children:[A.jsxs(Fd,{className:"max-w-105 mx-auto",children:[A.jsx(Cf,{children:"Authenticated"}),A.jsx(jf,{children:"Loading service..."}),A.jsx("div",{className:"flex justify-center mt-7",children:A.jsx(kd,{className:"animate-spin",size:24})})]}),A.jsx(Uf,{})]}):A.jsxs("main",{className:"mt-20",children:[A.jsxs(Fd,{className:"max-w-105 mx-auto",children:[A.jsx(Cf,{children:"Authentication Required"}),A.jsx(jf,{children:"The service you are trying to access is protected. Please authenticate to continue."}),A.jsxs("div",{className:"flex flex-col gap-4 mt-7 z-10 relative",children:[r&&A.jsx(Fh,{error:r}),Ft.oidc&&A.jsxs(Ru,{variant:"primary",className:"w-full",onClick:()=>{globalThis.location.href=Ft.oidc},children:[A.jsx(Im,{size:16}),"Sign in with SSO"]}),Ft.oidc&>&&A.jsx($h,{}),gt&&A.jsxs("form",{onSubmit:it=>{it.preventDefault(),st(R,R==="password"?D:_)},children:[zt&&A.jsx($e,{value:R,onChange:it=>{H(it),setTimeout(()=>{it==="password"?N.current?.focus():p.current?.focus()},0)},children:A.jsxs($e.List,{className:"rounded-lg border mb-4",children:[A.jsxs($e.Trigger,{value:"password",children:[A.jsx(Fm,{size:14}),"Password"]}),A.jsxs($e.Trigger,{value:"pin",children:[A.jsx(Km,{size:14}),"PIN"]})]})}),A.jsxs("div",{className:"mb-4",children:[Ft.password&&(R==="password"||!Ft.pin)&&A.jsxs(A.Fragment,{children:[!zt&&A.jsx(Id,{htmlFor:"password",children:"Password"}),A.jsx(c0,{ref:N,type:"password",id:"password",placeholder:"Enter password",disabled:S!==null,showPasswordToggle:!0,autoFocus:!0,value:D,onChange:it=>U(it.target.value)})]}),Ft.pin&&(R==="pin"||!Ft.password)&&A.jsxs(A.Fragment,{children:[!zt&&A.jsx(Id,{htmlFor:"pin-0",children:"Enter PIN Code"}),A.jsx(Zh,{ref:p,value:_,onChange:ct,disabled:S!==null,autoFocus:!Ft.password})]})]}),A.jsx(Ru,{type:"submit",disabled:L,variant:"secondary",className:"w-full",children:S===null?_t:A.jsxs(A.Fragment,{children:[A.jsx(kd,{className:"animate-spin",size:16}),"Verifying..."]})})]})]})]}),A.jsx(Uf,{})]})}function Ph({success:r=!0}){return r?A.jsx("div",{className:"flex-1 flex items-center justify-center h-12 w-full px-5",children:A.jsx("div",{className:"w-full border-t-2 border-dashed border-green-500"})}):A.jsxs("div",{className:"flex-1 flex items-center justify-center h-12 min-w-10 px-5 relative",children:[A.jsx("div",{className:"w-full border-t-2 border-dashed border-nb-gray-900"}),A.jsx("div",{className:"absolute inset-0 flex items-center justify-center",children:A.jsx("div",{className:"w-8 h-8 rounded-full flex items-center justify-center",children:A.jsx(eh,{size:18,className:"text-netbird"})})})]})}function Of({icon:r,label:v,detail:S,success:f=!0,line:_=!0}){return A.jsxs(A.Fragment,{children:[_&&A.jsx(Ph,{success:f}),A.jsxs("div",{className:"flex flex-col items-center gap-2",children:[A.jsx("div",{className:"w-14 h-14 rounded-md flex items-center justify-center from-nb-gray-940 to-nb-gray-930/70 bg-gradient-to-br border border-nb-gray-910",children:A.jsx(r,{size:20,className:"text-nb-gray-200"})}),A.jsx("span",{className:"text-sm text-nb-gray-200 font-normal mt-1",children:v}),A.jsx("span",{className:`text-xs font-medium uppercase ${f?"text-green-500":"text-netbird"}`,children:f?"Connected":"Unreachable"}),S&&A.jsx("span",{className:"text-xs text-nb-gray-400 truncate text-center",children:S})]})]})}function tg({code:r,title:v,message:S,proxy:f=!0,destination:_=!0,requestId:O,simple:D=!1,retryUrl:U}){xt.useEffect(()=>{document.title=`${v} - NetBird Service`},[v]);const[N]=xt.useState(()=>new Date().toISOString());return A.jsxs("main",{className:"flex flex-col items-center mt-24 px-4 max-w-3xl mx-auto",children:[A.jsxs("div",{className:"text-sm text-netbird font-normal font-mono mb-3 z-10 relative",children:["Error ",r]}),A.jsx(Cf,{className:"text-3xl!",children:v}),A.jsx(jf,{className:"mt-2 mb-8 max-w-md",children:S}),!D&&A.jsxs("div",{className:"hidden sm:flex items-start justify-center w-full mt-6 mb-16 z-10 relative",children:[A.jsx(Of,{icon:th,label:"You",line:!1}),A.jsx(Of,{icon:lh,label:"Proxy",success:f}),A.jsx(Of,{icon:$m,label:"Destination",success:_})]}),A.jsxs("div",{className:"flex gap-3 justify-center items-center mb-6 z-10 relative",children:[A.jsxs(Ru,{variant:"primary",onClick:()=>{U?globalThis.location.href=U:globalThis.location.reload()},children:[A.jsx(Pm,{size:16}),"Refresh Page"]}),A.jsxs(Ru,{variant:"secondary",onClick:()=>globalThis.open("https://docs.netbird.io","_blank","noopener,noreferrer"),children:[A.jsx(Jm,{size:16}),"Documentation"]})]}),A.jsxs("div",{className:"text-center text-xs text-nb-gray-300 uppercase z-10 relative font-mono flex flex-col sm:flex-row gap-2 sm:gap-10 mt-4 mb-3",children:[A.jsxs("div",{children:[A.jsx("span",{className:"text-nb-gray-400",children:"REQUEST-ID:"})," ",O]}),A.jsxs("div",{children:[A.jsx("span",{className:"text-nb-gray-400",children:"TIMESTAMP:"})," ",N]})]}),A.jsx(Uf,{})]})}const Nf=t0();Zm.createRoot(document.getElementById("root")).render(A.jsx(xt.StrictMode,{children:Nf.page==="error"&&Nf.error?A.jsx(tg,{...Nf.error}):A.jsx(Ih,{})})); diff --git a/proxy/web/dist/assets/netbird-full.svg b/proxy/web/dist/assets/netbird-full.svg new file mode 100644 index 000000000..f925d5761 --- /dev/null +++ b/proxy/web/dist/assets/netbird-full.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/proxy/web/dist/assets/style.css b/proxy/web/dist/assets/style.css new file mode 100644 index 000000000..95a00c303 --- /dev/null +++ b/proxy/web/dist/assets/style.css @@ -0,0 +1 @@ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-pan-x:initial;--tw-pan-y:initial;--tw-pinch-zoom:initial;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-x-reverse:0;--tw-border-style:solid;--tw-divide-y-reverse:0;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-100:#fde8e8;--color-red-400:#f98080;--color-red-500:#f05252;--color-red-600:#e02424;--color-red-700:#c81e1e;--color-red-800:#9b1c1c;--color-red-950:oklch(25.8% .092 26.042);--color-green-500:#0e9f6e;--color-gray-100:#f3f4f6;--color-gray-200:#e5e7eb;--color-gray-400:#9ca3af;--color-gray-500:#6b7280;--color-gray-700:#374151;--color-gray-800:#1f2937;--color-gray-900:#111827;--color-zinc-50:oklch(98.5% 0 0);--color-zinc-200:oklch(92% .004 286.32);--color-zinc-800:oklch(27.4% .006 286.033);--color-neutral-200:oklch(92.2% 0 0);--color-neutral-400:oklch(70.8% 0 0);--color-neutral-500:oklch(55.6% 0 0);--color-neutral-950:oklch(14.5% 0 0);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-3xl:48rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height: 1.2 ;--font-weight-light:300;--font-weight-normal:400;--font-weight-medium:500;--tracking-wide:.025em;--tracking-wider:.05em;--radius-md:.375rem;--radius-lg:.5rem;--animate-spin:spin 1s linear infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-nb-gray:#181a1d;--color-nb-gray-100:#e4e7e9;--color-nb-gray-200:#cbd2d6;--color-nb-gray-300:#aab4bd;--color-nb-gray-400:#7c8994;--color-nb-gray-500:#616e79;--color-nb-gray-700:#474e57;--color-nb-gray-800:#3f444b;--color-nb-gray-900:#32363d;--color-nb-gray-910:#2b2f33;--color-nb-gray-920:#25282d;--color-nb-gray-930:#25282c;--color-nb-gray-940:#1c1e21;--color-nb-gray-950:#181a1d;--color-netbird:#f68330;--color-netbird-400:#f68330;--color-netbird-500:#f46d1b;--color-netbird-600:#e55311}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.not-sr-only{clip-path:none;white-space:normal;width:auto;height:auto;margin:0;padding:0;position:static;overflow:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing)*0)}.top-0{top:calc(var(--spacing)*0)}.right-0{right:calc(var(--spacing)*0)}.left-0{left:calc(var(--spacing)*0)}.isolate{isolation:isolate}.isolation-auto{isolation:auto}.z-0{z-index:0}.z-10{z-index:10}.container{width:100%}@media(min-width:40rem){.container{max-width:40rem}}@media(min-width:48rem){.container{max-width:48rem}}@media(min-width:64rem){.container{max-width:64rem}}@media(min-width:80rem){.container{max-width:80rem}}@media(min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.my-4{margin-block:calc(var(--spacing)*4)}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-6{margin-top:calc(var(--spacing)*6)}.mt-7{margin-top:calc(var(--spacing)*7)}.mt-8{margin-top:calc(var(--spacing)*8)}.mt-20{margin-top:calc(var(--spacing)*20)}.mt-24{margin-top:calc(var(--spacing)*24)}.mb-2\.5{margin-bottom:calc(var(--spacing)*2.5)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.mb-6{margin-bottom:calc(var(--spacing)*6)}.mb-8{margin-bottom:calc(var(--spacing)*8)}.mb-16{margin-bottom:calc(var(--spacing)*16)}.ml-4{margin-left:calc(var(--spacing)*4)}.block{display:block}.contents{display:contents}.flex{display:flex}.flow-root{display:flow-root}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.inline-grid{display:inline-grid}.inline-table{display:inline-table}.list-item{display:list-item}.table{display:table}.table-caption{display:table-caption}.table-cell{display:table-cell}.table-column{display:table-column}.table-column-group{display:table-column-group}.table-footer-group{display:table-footer-group}.table-header-group{display:table-header-group}.table-row{display:table-row}.table-row-group{display:table-row-group}.h-8{height:calc(var(--spacing)*8)}.h-12{height:calc(var(--spacing)*12)}.h-14{height:calc(var(--spacing)*14)}.h-\[42px\]{height:42px}.h-full{height:100%}.h-px{height:1px}.w-8{width:calc(var(--spacing)*8)}.w-14{width:calc(var(--spacing)*14)}.w-auto{width:auto}.w-full{width:100%}.max-w-3xl{max-width:var(--container-3xl)}.max-w-105{max-width:calc(var(--spacing)*105)}.max-w-md{max-width:var(--container-md)}.min-w-0{min-width:calc(var(--spacing)*0)}.min-w-10{min-width:calc(var(--spacing)*10)}.flex-1{flex:1}.shrink{flex-shrink:1}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.transform{transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.animate-spin{animation:var(--animate-spin)}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.touch-pinch-zoom{--tw-pinch-zoom:pinch-zoom;touch-action:var(--tw-pan-x,)var(--tw-pan-y,)var(--tw-pinch-zoom,)}.resize{resize:both}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-center{justify-content:center}.gap-1{gap:calc(var(--spacing)*1)}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}:where(.space-y-reverse>:not(:last-child)){--tw-space-y-reverse:1}:where(.space-x-reverse>:not(:last-child)){--tw-space-x-reverse:1}:where(.divide-x>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(1px*var(--tw-divide-x-reverse));border-inline-end-width:calc(1px*calc(1 - var(--tw-divide-x-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px*var(--tw-divide-y-reverse));border-bottom-width:calc(1px*calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-y-reverse>:not(:last-child)){--tw-divide-y-reverse:1}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-s{border-start-start-radius:.25rem;border-end-start-radius:.25rem}.rounded-ss{border-start-start-radius:.25rem}.rounded-e{border-start-end-radius:.25rem;border-end-end-radius:.25rem}.rounded-se{border-start-end-radius:.25rem}.rounded-ee{border-end-end-radius:.25rem}.rounded-es{border-end-start-radius:.25rem}.rounded-t{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.\!rounded-l-none{border-top-left-radius:0!important;border-bottom-left-radius:0!important}.rounded-l{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-l-md{border-top-left-radius:var(--radius-md);border-bottom-left-radius:var(--radius-md)}.rounded-tl{border-top-left-radius:.25rem}.rounded-r{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-tr{border-top-right-radius:.25rem}.rounded-b{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-b-md{border-bottom-right-radius:var(--radius-md);border-bottom-left-radius:var(--radius-md)}.rounded-br{border-bottom-right-radius:.25rem}.rounded-bl{border-bottom-left-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.border-x{border-inline-style:var(--tw-border-style);border-inline-width:1px}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-s{border-inline-start-style:var(--tw-border-style);border-inline-start-width:1px}.border-e{border-inline-end-style:var(--tw-border-style);border-inline-end-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-t-0{border-top-style:var(--tw-border-style);border-top-width:0}.border-t-2{border-top-style:var(--tw-border-style);border-top-width:2px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-0{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.\!border-l-0{border-left-style:var(--tw-border-style)!important;border-left-width:0!important}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-gray-200{border-color:var(--color-gray-200)}.border-green-500{border-color:var(--color-green-500)}.border-nb-gray-700{border-color:var(--color-nb-gray-700)}.border-nb-gray-800{border-color:var(--color-nb-gray-800)}.border-nb-gray-900{border-color:var(--color-nb-gray-900)}.border-nb-gray-910{border-color:var(--color-nb-gray-910)}.border-neutral-200{border-color:var(--color-neutral-200)}.border-red-500{border-color:var(--color-red-500)}.border-red-800\/50{border-color:#9b1c1c80}@supports (color:color-mix(in lab,red,red)){.border-red-800\/50{border-color:color-mix(in oklab,var(--color-red-800)50%,transparent)}}.border-transparent{border-color:#0000}.border-white{border-color:var(--color-white)}.bg-nb-gray-900{background-color:var(--color-nb-gray-900)}.bg-nb-gray-920{background-color:var(--color-nb-gray-920)}.bg-nb-gray-930\/70{background-color:#25282cb3}@supports (color:color-mix(in lab,red,red)){.bg-nb-gray-930\/70{background-color:color-mix(in oklab,var(--color-nb-gray-930)70%,transparent)}}.bg-nb-gray-940{background-color:var(--color-nb-gray-940)}.bg-red-800\/20{background-color:#9b1c1c33}@supports (color:color-mix(in lab,red,red)){.bg-red-800\/20{background-color:color-mix(in oklab,var(--color-red-800)20%,transparent)}}.bg-white{background-color:var(--color-white)}.bg-linear-to-b{--tw-gradient-position:to bottom}@supports (background-image:linear-gradient(in lab,red,red)){.bg-linear-to-b{--tw-gradient-position:to bottom in oklab}}.bg-linear-to-b{background-image:linear-gradient(var(--tw-gradient-stops))}.bg-gradient-to-br{--tw-gradient-position:to bottom right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-nb-gray-900\/10{--tw-gradient-from:#32363d1a}@supports (color:color-mix(in lab,red,red)){.from-nb-gray-900\/10{--tw-gradient-from:color-mix(in oklab,var(--color-nb-gray-900)10%,transparent)}}.from-nb-gray-900\/10{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-nb-gray-940{--tw-gradient-from:var(--color-nb-gray-940);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.via-transparent{--tw-gradient-via:transparent;--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.to-nb-gray-930\/70{--tw-gradient-to:#25282cb3}@supports (color:color-mix(in lab,red,red)){.to-nb-gray-930\/70{--tw-gradient-to:color-mix(in oklab,var(--color-nb-gray-930)70%,transparent)}}.to-nb-gray-930\/70{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-transparent{--tw-gradient-to:transparent;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.bg-repeat{background-repeat:repeat}.p-1\.5{padding:calc(var(--spacing)*1.5)}.\!px-0{padding-inline:calc(var(--spacing)*0)!important}.px-3{padding-inline:calc(var(--spacing)*3)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-5{padding-inline:calc(var(--spacing)*5)}.px-6{padding-inline:calc(var(--spacing)*6)}.\!py-0{padding-block:calc(var(--spacing)*0)!important}.py-2{padding-block:calc(var(--spacing)*2)}.py-2\.5{padding-block:calc(var(--spacing)*2.5)}.py-3{padding-block:calc(var(--spacing)*3)}.py-10{padding-block:calc(var(--spacing)*10)}.pt-4{padding-top:calc(var(--spacing)*4)}.pt-8{padding-top:calc(var(--spacing)*8)}.\!pr-16{padding-right:calc(var(--spacing)*16)!important}.pr-4{padding-right:calc(var(--spacing)*4)}.pb-5{padding-bottom:calc(var(--spacing)*5)}.\!pl-10{padding-left:calc(var(--spacing)*10)!important}.pl-3{padding-left:calc(var(--spacing)*3)}.text-center{text-align:center}.font-mono{font-family:var(--font-mono)}.text-3xl\!{font-size:var(--text-3xl)!important;line-height:var(--tw-leading,var(--text-3xl--line-height))!important}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl\!{font-size:var(--text-xl)!important;line-height:var(--tw-leading,var(--text-xl--line-height))!important}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[\.8rem\]{font-size:.8rem}.text-\[0\.78rem\]{font-size:.78rem}.leading-\[0\]{--tw-leading:0;line-height:0}.leading-none{--tw-leading:1;line-height:1}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.text-wrap{text-wrap:wrap}.text-clip{text-overflow:clip}.text-ellipsis{text-overflow:ellipsis}.whitespace-break-spaces{white-space:break-spaces}.whitespace-nowrap{white-space:nowrap}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-green-500{color:var(--color-green-500)}.text-nb-gray-200{color:var(--color-nb-gray-200)}.text-nb-gray-300{color:var(--color-nb-gray-300)}.text-nb-gray-400{color:var(--color-nb-gray-400)}.text-netbird{color:var(--color-netbird)}.text-red-400{color:var(--color-red-400)}.text-red-500{color:var(--color-red-500)}.text-white{color:var(--color-white)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.italic{font-style:italic}.not-italic{font-style:normal}.diagonal-fractions{--tw-numeric-fraction:diagonal-fractions;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.lining-nums{--tw-numeric-figure:lining-nums;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.oldstyle-nums{--tw-numeric-figure:oldstyle-nums;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.proportional-nums{--tw-numeric-spacing:proportional-nums;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.slashed-zero{--tw-slashed-zero:slashed-zero;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.stacked-fractions{--tw-numeric-fraction:stacked-fractions;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.normal-nums{font-variant-numeric:normal}.line-through{text-decoration-line:line-through}.no-underline{text-decoration-line:none}.overline{text-decoration-line:overline}.underline{text-decoration-line:underline}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.subpixel-antialiased{-webkit-font-smoothing:auto;-moz-osx-font-smoothing:auto}.opacity-30{opacity:.3}.opacity-40{opacity:.4}.\!shadow-none{--tw-shadow:0 0 #0000!important;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)!important}.shadow,.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-offset-neutral-200\/20{--tw-ring-offset-color:#e5e5e533}@supports (color:color-mix(in lab,red,red)){.ring-offset-neutral-200\/20{--tw-ring-offset-color:color-mix(in oklab,var(--color-neutral-200)20%,transparent)}}.ring-offset-neutral-950\/50{--tw-ring-offset-color:#0a0a0a80}@supports (color:color-mix(in lab,red,red)){.ring-offset-neutral-950\/50{--tw-ring-offset-color:color-mix(in oklab,var(--color-neutral-950)50%,transparent)}}.ring-offset-red-500\/10{--tw-ring-offset-color:#f052521a}@supports (color:color-mix(in lab,red,red)){.ring-offset-red-500\/10{--tw-ring-offset-color:color-mix(in oklab,var(--color-red-500)10%,transparent)}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.drop-shadow{--tw-drop-shadow-size:drop-shadow(0 1px 2px var(--tw-drop-shadow-color,#0000001a))drop-shadow(0 1px 1px var(--tw-drop-shadow-color,#0000000f));--tw-drop-shadow:drop-shadow(0 1px 2px #0000001a)drop-shadow(0 1px 1px #0000000f);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.grayscale{--tw-grayscale:grayscale(100%);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.sepia{--tw-sepia:sepia(100%);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-grayscale{--tw-backdrop-grayscale:grayscale(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-invert{--tw-backdrop-invert:invert(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-sepia{--tw-backdrop-sepia:sepia(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}:where(.divide-x-reverse>:not(:last-child)){--tw-divide-x-reverse:1}.ring-inset{--tw-ring-inset:inset}@media(hover:hover){.group-hover\:opacity-80:is(:where(.group):hover *){opacity:.8}}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-70:is(:where(.peer):disabled~*){opacity:.7}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-transparent::file-selector-button{background-color:#0000}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.file\:font-medium::file-selector-button{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.placeholder\:text-neutral-400\/70::placeholder{color:#a1a1a1b3}@supports (color:color-mix(in lab,red,red)){.placeholder\:text-neutral-400\/70::placeholder{color:color-mix(in oklab,var(--color-neutral-400)70%,transparent)}}@media(hover:hover){.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}.hover\:bg-nb-gray-900\/50:hover{background-color:#32363d80}@supports (color:color-mix(in lab,red,red)){.hover\:bg-nb-gray-900\/50:hover{background-color:color-mix(in oklab,var(--color-nb-gray-900)50%,transparent)}}.hover\:bg-neutral-200:hover{background-color:var(--color-neutral-200)}.hover\:text-black:hover{color:var(--color-black)}.hover\:text-white:hover{color:var(--color-white)}}.focus\:z-10:focus{z-index:10}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-red-500\/30:focus{--tw-ring-color:#f052524d}@supports (color:color-mix(in lab,red,red)){.focus\:ring-red-500\/30:focus{--tw-ring-color:color-mix(in oklab,var(--color-red-500)30%,transparent)}}.focus\:ring-white\/50:focus{--tw-ring-color:#ffffff80}@supports (color:color-mix(in lab,red,red)){.focus\:ring-white\/50:focus{--tw-ring-color:color-mix(in oklab,var(--color-white)50%,transparent)}}.focus\:ring-zinc-200\/50:focus{--tw-ring-color:#e4e4e780}@supports (color:color-mix(in lab,red,red)){.focus\:ring-zinc-200\/50:focus{--tw-ring-color:color-mix(in oklab,var(--color-zinc-200)50%,transparent)}}.focus\:ring-offset-1:focus{--tw-ring-offset-width:1px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-neutral-500\/20:focus-visible{--tw-ring-color:#73737333}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-neutral-500\/20:focus-visible{--tw-ring-color:color-mix(in oklab,var(--color-neutral-500)20%,transparent)}}.focus-visible\:ring-red-500\/10:focus-visible{--tw-ring-color:#f052521a}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-red-500\/10:focus-visible{--tw-ring-color:color-mix(in oklab,var(--color-red-500)10%,transparent)}}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.enabled\:bg-netbird:enabled{background-color:var(--color-netbird)}.enabled\:text-white:enabled{color:var(--color-white)}@media(hover:hover){.enabled\:hover\:bg-netbird-500:enabled:hover{background-color:var(--color-netbird-500)}}.enabled\:focus\:ring-netbird-400\/50:enabled:focus{--tw-ring-color:#f6833080}@supports (color:color-mix(in lab,red,red)){.enabled\:focus\:ring-netbird-400\/50:enabled:focus{--tw-ring-color:color-mix(in oklab,var(--color-netbird-400)50%,transparent)}}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:text-nb-gray-300:disabled{color:var(--color-nb-gray-300)}.disabled\:opacity-40:disabled{opacity:.4}@media(min-width:40rem){.sm\:flex{display:flex}.sm\:flex-row{flex-direction:row}.sm\:gap-10{gap:calc(var(--spacing)*10)}.sm\:px-10{padding-inline:calc(var(--spacing)*10)}}@media(min-width:48rem){.md\:block{display:block}.md\:hidden{display:none}}.dark\:border-gray-500\/40:where(.dark,.dark *){border-color:#6b728066}@supports (color:color-mix(in lab,red,red)){.dark\:border-gray-500\/40:where(.dark,.dark *){border-color:color-mix(in oklab,var(--color-gray-500)40%,transparent)}}.dark\:border-gray-700\/30:where(.dark,.dark *){border-color:#3741514d}@supports (color:color-mix(in lab,red,red)){.dark\:border-gray-700\/30:where(.dark,.dark *){border-color:color-mix(in oklab,var(--color-gray-700)30%,transparent)}}.dark\:border-gray-700\/40:where(.dark,.dark *){border-color:#37415166}@supports (color:color-mix(in lab,red,red)){.dark\:border-gray-700\/40:where(.dark,.dark *){border-color:color-mix(in oklab,var(--color-gray-700)40%,transparent)}}.dark\:border-gray-700\/70:where(.dark,.dark *){border-color:#374151b3}@supports (color:color-mix(in lab,red,red)){.dark\:border-gray-700\/70:where(.dark,.dark *){border-color:color-mix(in oklab,var(--color-gray-700)70%,transparent)}}.dark\:border-nb-gray-700:where(.dark,.dark *){border-color:var(--color-nb-gray-700)}.dark\:border-nb-gray-900:where(.dark,.dark *){border-color:var(--color-nb-gray-900)}.dark\:border-netbird:where(.dark,.dark *){border-color:var(--color-netbird)}.dark\:border-transparent:where(.dark,.dark *){border-color:#0000}.dark\:bg-nb-gray:where(.dark,.dark *){background-color:var(--color-nb-gray)}.dark\:bg-nb-gray-900:where(.dark,.dark *){background-color:var(--color-nb-gray-900)}.dark\:bg-nb-gray-900\/30:where(.dark,.dark *){background-color:#32363d4d}@supports (color:color-mix(in lab,red,red)){.dark\:bg-nb-gray-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-nb-gray-900)30%,transparent)}}.dark\:bg-nb-gray-900\/40:where(.dark,.dark *){background-color:#32363d66}@supports (color:color-mix(in lab,red,red)){.dark\:bg-nb-gray-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-nb-gray-900)40%,transparent)}}.dark\:bg-nb-gray-900\/70:where(.dark,.dark *){background-color:#32363db3}@supports (color:color-mix(in lab,red,red)){.dark\:bg-nb-gray-900\/70:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-nb-gray-900)70%,transparent)}}.dark\:bg-nb-gray-920:where(.dark,.dark *){background-color:var(--color-nb-gray-920)}.dark\:bg-red-600:where(.dark,.dark *){background-color:var(--color-red-600)}.dark\:bg-transparent:where(.dark,.dark *){background-color:#0000}.dark\:bg-white:where(.dark,.dark *){background-color:var(--color-white)}.dark\:text-gray-100:where(.dark,.dark *){color:var(--color-gray-100)}.dark\:text-gray-400:where(.dark,.dark *){color:var(--color-gray-400)}.dark\:text-gray-800:where(.dark,.dark *){color:var(--color-gray-800)}.dark\:text-nb-gray-400:where(.dark,.dark *){color:var(--color-nb-gray-400)}.dark\:text-netbird:where(.dark,.dark *){color:var(--color-netbird)}.dark\:text-red-100:where(.dark,.dark *){color:var(--color-red-100)}.dark\:text-red-500:where(.dark,.dark *){color:var(--color-red-500)}.dark\:ring-offset-nb-gray-950\/50:where(.dark,.dark *){--tw-ring-offset-color:#181a1d80}@supports (color:color-mix(in lab,red,red)){.dark\:ring-offset-nb-gray-950\/50:where(.dark,.dark *){--tw-ring-offset-color:color-mix(in oklab,var(--color-nb-gray-950)50%,transparent)}}.dark\:ring-offset-neutral-950\/50:where(.dark,.dark *){--tw-ring-offset-color:#0a0a0a80}@supports (color:color-mix(in lab,red,red)){.dark\:ring-offset-neutral-950\/50:where(.dark,.dark *){--tw-ring-offset-color:color-mix(in oklab,var(--color-neutral-950)50%,transparent)}}.dark\:placeholder\:text-neutral-400\/70:where(.dark,.dark *)::placeholder{color:#a1a1a1b3}@supports (color:color-mix(in lab,red,red)){.dark\:placeholder\:text-neutral-400\/70:where(.dark,.dark *)::placeholder{color:color-mix(in oklab,var(--color-neutral-400)70%,transparent)}}@media(hover:hover){.dark\:hover\:border-nb-gray-800\/50:where(.dark,.dark *):hover{border-color:#3f444b80}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:border-nb-gray-800\/50:where(.dark,.dark *):hover{border-color:color-mix(in oklab,var(--color-nb-gray-800)50%,transparent)}}.dark\:hover\:border-red-800\/50:where(.dark,.dark *):hover{border-color:#9b1c1c80}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:border-red-800\/50:where(.dark,.dark *):hover{border-color:color-mix(in oklab,var(--color-red-800)50%,transparent)}}.dark\:hover\:bg-nb-gray-800\/60:where(.dark,.dark *):hover{background-color:#3f444b99}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-nb-gray-800\/60:where(.dark,.dark *):hover{background-color:color-mix(in oklab,var(--color-nb-gray-800)60%,transparent)}}.dark\:hover\:bg-nb-gray-900\/30:where(.dark,.dark *):hover{background-color:#32363d4d}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-nb-gray-900\/30:where(.dark,.dark *):hover{background-color:color-mix(in oklab,var(--color-nb-gray-900)30%,transparent)}}.dark\:hover\:bg-nb-gray-900\/50:where(.dark,.dark *):hover{background-color:#32363d80}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-nb-gray-900\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab,var(--color-nb-gray-900)50%,transparent)}}.dark\:hover\:bg-nb-gray-900\/80:where(.dark,.dark *):hover{background-color:#32363dcc}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-nb-gray-900\/80:where(.dark,.dark *):hover{background-color:color-mix(in oklab,var(--color-nb-gray-900)80%,transparent)}}.dark\:hover\:bg-nb-gray-910:where(.dark,.dark *):hover{background-color:var(--color-nb-gray-910)}.dark\:hover\:bg-neutral-200:where(.dark,.dark *):hover{background-color:var(--color-neutral-200)}.dark\:hover\:bg-zinc-800\/50:where(.dark,.dark *):hover{background-color:#27272a80}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-zinc-800\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab,var(--color-zinc-800)50%,transparent)}}.hover\:dark\:bg-red-700:hover:where(.dark,.dark *){background-color:var(--color-red-700)}.dark\:hover\:text-red-600:where(.dark,.dark *):hover{color:var(--color-red-600)}.dark\:hover\:text-white:where(.dark,.dark *):hover{color:var(--color-white)}}.dark\:focus\:bg-red-700:where(.dark,.dark *):focus{background-color:var(--color-red-700)}.dark\:focus\:ring-nb-gray-500\/20:where(.dark,.dark *):focus{--tw-ring-color:#616e7933}@supports (color:color-mix(in lab,red,red)){.dark\:focus\:ring-nb-gray-500\/20:where(.dark,.dark *):focus{--tw-ring-color:color-mix(in oklab,var(--color-nb-gray-500)20%,transparent)}}.dark\:focus\:ring-netbird-600\/50:where(.dark,.dark *):focus{--tw-ring-color:#e5531180}@supports (color:color-mix(in lab,red,red)){.dark\:focus\:ring-netbird-600\/50:where(.dark,.dark *):focus{--tw-ring-color:color-mix(in oklab,var(--color-netbird-600)50%,transparent)}}.dark\:focus\:ring-neutral-500\/20:where(.dark,.dark *):focus{--tw-ring-color:#73737333}@supports (color:color-mix(in lab,red,red)){.dark\:focus\:ring-neutral-500\/20:where(.dark,.dark *):focus{--tw-ring-color:color-mix(in oklab,var(--color-neutral-500)20%,transparent)}}.dark\:focus\:ring-red-700\/20:where(.dark,.dark *):focus{--tw-ring-color:#c81e1e33}@supports (color:color-mix(in lab,red,red)){.dark\:focus\:ring-red-700\/20:where(.dark,.dark *):focus{--tw-ring-color:color-mix(in oklab,var(--color-red-700)20%,transparent)}}.dark\:focus\:ring-zinc-800\/50:where(.dark,.dark *):focus{--tw-ring-color:#27272a80}@supports (color:color-mix(in lab,red,red)){.dark\:focus\:ring-zinc-800\/50:where(.dark,.dark *):focus{--tw-ring-color:color-mix(in oklab,var(--color-zinc-800)50%,transparent)}}.dark\:focus-visible\:ring-neutral-500\/20:where(.dark,.dark *):focus-visible{--tw-ring-color:#73737333}@supports (color:color-mix(in lab,red,red)){.dark\:focus-visible\:ring-neutral-500\/20:where(.dark,.dark *):focus-visible{--tw-ring-color:color-mix(in oklab,var(--color-neutral-500)20%,transparent)}}.enabled\:dark\:bg-netbird:enabled:where(.dark,.dark *){background-color:var(--color-netbird)}@media(hover:hover){.enabled\:dark\:hover\:border-red-800\/50:enabled:where(.dark,.dark *):hover{border-color:#9b1c1c80}@supports (color:color-mix(in lab,red,red)){.enabled\:dark\:hover\:border-red-800\/50:enabled:where(.dark,.dark *):hover{border-color:color-mix(in oklab,var(--color-red-800)50%,transparent)}}.enabled\:dark\:hover\:bg-netbird-500\/80:enabled:where(.dark,.dark *):hover{background-color:#f46d1bcc}@supports (color:color-mix(in lab,red,red)){.enabled\:dark\:hover\:bg-netbird-500\/80:enabled:where(.dark,.dark *):hover{background-color:color-mix(in oklab,var(--color-netbird-500)80%,transparent)}}.enabled\:hover\:dark\:bg-red-950\/50:enabled:hover:where(.dark,.dark *){background-color:#46080980}@supports (color:color-mix(in lab,red,red)){.enabled\:hover\:dark\:bg-red-950\/50:enabled:hover:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-red-950)50%,transparent)}}.enabled\:dark\:hover\:text-white:enabled:where(.dark,.dark *):hover{color:var(--color-white)}}.enabled\:dark\:focus\:bg-red-950\/40:enabled:where(.dark,.dark *):focus{background-color:#46080966}@supports (color:color-mix(in lab,red,red)){.enabled\:dark\:focus\:bg-red-950\/40:enabled:where(.dark,.dark *):focus{background-color:color-mix(in oklab,var(--color-red-950)40%,transparent)}}.enabled\:dark\:focus\:ring-red-800\/20:enabled:where(.dark,.dark *):focus{--tw-ring-color:#9b1c1c33}@supports (color:color-mix(in lab,red,red)){.enabled\:dark\:focus\:ring-red-800\/20:enabled:where(.dark,.dark *):focus{--tw-ring-color:color-mix(in oklab,var(--color-red-800)20%,transparent)}}.disabled\:dark\:border-nb-gray-900:disabled:where(.dark,.dark *){border-color:var(--color-nb-gray-900)}.disabled\:dark\:bg-nb-gray-900:disabled:where(.dark,.dark *){background-color:var(--color-nb-gray-900)}.disabled\:dark\:bg-nb-gray-910:disabled:where(.dark,.dark *){background-color:var(--color-nb-gray-910)}.disabled\:dark\:bg-nb-gray-920:disabled:where(.dark,.dark *){background-color:var(--color-nb-gray-920)}.disabled\:dark\:text-nb-gray-300:disabled:where(.dark,.dark *){color:var(--color-nb-gray-300)}.data-\[state\=open\]\:dark\:border-nb-gray-800\/50[data-state=open]:where(.dark,.dark *){border-color:#3f444b80}@supports (color:color-mix(in lab,red,red)){.data-\[state\=open\]\:dark\:border-nb-gray-800\/50[data-state=open]:where(.dark,.dark *){border-color:color-mix(in oklab,var(--color-nb-gray-800)50%,transparent)}}.data-\[state\=open\]\:dark\:bg-nb-gray-900\/30[data-state=open]:where(.dark,.dark *){background-color:#32363d4d}@supports (color:color-mix(in lab,red,red)){.data-\[state\=open\]\:dark\:bg-nb-gray-900\/30[data-state=open]:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-nb-gray-900)30%,transparent)}}.data-\[state\=open\]\:dark\:text-white[data-state=open]:where(.dark,.dark *){color:var(--color-white)}}@font-face{font-family:Inter;font-style:normal;font-weight:100 900;font-display:swap;src:url(/__netbird__/assets/Inter-VariableFont_opsz_wght.ttf)format("truetype")}@font-face{font-family:Inter;font-style:italic;font-weight:100 900;font-display:swap;src:url(/__netbird__/assets/Inter-Italic-VariableFont_opsz_wght.ttf)format("truetype")}:root{--nb-bg:#18191d;--nb-card-bg:#1b1f22;--nb-border:#32363d80;--nb-text:#e4e7e9;--nb-text-muted:#a7b1b9cc;--nb-primary:#f68330;--nb-primary-hover:#e5722a;--nb-input-bg:#3f444b80;--nb-input-border:#3f444bcc;--nb-error-bg:#991b1b33;--nb-error-border:#991b1b80;--nb-error-text:#f87171}html{color-scheme:dark;background-color:var(--color-nb-gray)}html.dark,:root{color-scheme:dark}body{font-family:Inter,ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji}h1{margin-block:calc(var(--spacing)*1);font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-700)}h1:where(.dark,.dark *){color:var(--color-nb-gray-100)}h2{margin-block:calc(var(--spacing)*1);font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-700)}h2:where(.dark,.dark *){color:var(--color-nb-gray-100)}p{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light);--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide);color:var(--color-gray-700)}p:where(.dark,.dark *){color:var(--color-zinc-50)}[placeholder]{text-overflow:ellipsis}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-pan-x{syntax:"*";inherits:false}@property --tw-pan-y{syntax:"*";inherits:false}@property --tw-pinch-zoom{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}} diff --git a/proxy/web/dist/index.html b/proxy/web/dist/index.html new file mode 100644 index 000000000..ea253a77d --- /dev/null +++ b/proxy/web/dist/index.html @@ -0,0 +1,19 @@ + + + + + + + NetBird Service + + + + + + + +
+ + diff --git a/proxy/web/dist/robots.txt b/proxy/web/dist/robots.txt new file mode 100644 index 000000000..1f53798bb --- /dev/null +++ b/proxy/web/dist/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / diff --git a/proxy/web/eslint.config.js b/proxy/web/eslint.config.js new file mode 100644 index 000000000..5e6b472f5 --- /dev/null +++ b/proxy/web/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/proxy/web/index.html b/proxy/web/index.html new file mode 100644 index 000000000..e41f24f38 --- /dev/null +++ b/proxy/web/index.html @@ -0,0 +1,18 @@ + + + + + + + NetBird Service + + + + + +
+ + + diff --git a/proxy/web/package-lock.json b/proxy/web/package-lock.json new file mode 100644 index 000000000..d16196d77 --- /dev/null +++ b/proxy/web/package-lock.json @@ -0,0 +1,3952 @@ +{ + "name": "web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "0.0.0", + "dependencies": { + "clsx": "^2.1.1", + "lucide-react": "^0.468.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "tailwind-merge": "^2.6.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@tailwindcss/vite": "^4.1.18", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "tailwindcss": "^4.1.18", + "tsx": "^4.21.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.0.tgz", + "integrity": "sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", + "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "tailwindcss": "4.1.18" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.10.tgz", + "integrity": "sha512-+0/4J266CBGPUq/ELg7QUHhN25WYjE0wYTPSQJn1xeu8DOlIOPxXxrNGiLmfAWl7HMMgWFWXpt9IDjMWrF5Iow==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.10", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", + "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", + "integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/type-utils": "8.54.0", + "@typescript-eslint/utils": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.54.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", + "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz", + "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.54.0", + "@typescript-eslint/types": "^8.54.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", + "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz", + "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz", + "integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/utils": "8.54.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", + "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", + "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.54.0", + "@typescript-eslint/tsconfig-utils": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", + "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", + "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.3.tgz", + "integrity": "sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.2", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001767", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001767.tgz", + "integrity": "sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.18.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", + "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.1.tgz", + "integrity": "sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.54.0.tgz", + "integrity": "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.54.0", + "@typescript-eslint/parser": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/utils": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/proxy/web/package.json b/proxy/web/package.json new file mode 100644 index 000000000..97ec1ec0d --- /dev/null +++ b/proxy/web/package.json @@ -0,0 +1,36 @@ +{ + "name": "web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "clsx": "^2.1.1", + "lucide-react": "^0.468.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "tailwind-merge": "^2.6.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@tailwindcss/vite": "^4.1.18", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "tailwindcss": "^4.1.18", + "tsx": "^4.21.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4" + } +} diff --git a/proxy/web/public/robots.txt b/proxy/web/public/robots.txt new file mode 100644 index 000000000..1f53798bb --- /dev/null +++ b/proxy/web/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / diff --git a/proxy/web/src/App.tsx b/proxy/web/src/App.tsx new file mode 100644 index 000000000..ab453aa3e --- /dev/null +++ b/proxy/web/src/App.tsx @@ -0,0 +1,227 @@ +import { useState, useRef, useEffect } from "react"; +import {Loader2, Lock, Binary, LogIn} from "lucide-react"; +import { getData, type Data } from "@/data"; +import Button from "@/components/Button"; +import { Input } from "@/components/Input"; +import PinCodeInput, { type PinCodeInputRef } from "@/components/PinCodeInput"; +import { SegmentedTabs } from "@/components/SegmentedTabs"; +import { PoweredByNetBird } from "@/components/PoweredByNetBird"; +import { Card } from "@/components/Card"; +import { Title } from "@/components/Title"; +import { Description } from "@/components/Description"; +import { Separator } from "@/components/Separator"; +import { ErrorMessage } from "@/components/ErrorMessage"; +import { Label } from "@/components/Label"; + +const data = getData(); + +// For testing, show all methods if none are configured +const methods: NonNullable = + data.methods && Object.keys(data.methods).length > 0 + ? data.methods + : { password:"password", pin: "pin", oidc: "/auth/oidc" }; + +function App() { + useEffect(() => { + document.title = "Authentication Required - NetBird Service"; + }, []); + + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(null); + const [pin, setPin] = useState(""); + const [password, setPassword] = useState(""); + const passwordRef = useRef(null); + const pinRef = useRef(null); + const [activeTab, setActiveTab] = useState<"password" | "pin">( + methods.password ? "password" : "pin" + ); + + const handleAuthError = (method: "password" | "pin", message: string) => { + setError(message); + setSubmitting(null); + if (method === "password") { + setPassword(""); + setTimeout(() => passwordRef.current?.focus(), 200); + } else { + setPin(""); + setTimeout(() => pinRef.current?.focus(), 200); + } + }; + + const submitCredentials = (method: "password" | "pin", value: string) => { + setError(null); + setSubmitting(method); + + const formData = new FormData(); + if (method === "password") { + formData.append(methods.password!, value); + } else { + formData.append(methods.pin!, value); + } + + fetch(globalThis.location.href, { + method: "POST", + body: formData, + redirect: "manual", + }) + .then((res) => { + if (res.type === "opaqueredirect" || res.status === 0) { + setSubmitting("redirect"); + globalThis.location.reload(); + } else { + handleAuthError(method, "Authentication failed. Please try again."); + } + }) + .catch(() => { + handleAuthError(method, "An error occurred. Please try again."); + }); + }; + + const handlePinChange = (value: string) => { + setPin(value); + if (value.length === 6) { + submitCredentials("pin", value); + } + }; + + const isPinComplete = pin.length === 6; + const isPasswordEntered = password.length > 0; + const isButtonDisabled = submitting !== null || + (activeTab === "password" && !isPasswordEntered) || + (activeTab === "pin" && !isPinComplete); + + const hasCredentialAuth = methods.password || methods.pin; + const hasBothCredentials = methods.password && methods.pin; + const buttonLabel = activeTab === "password" ? "Sign in" : "Submit"; + + if (submitting === "redirect") { + return ( +
+ + Authenticated + Loading service... +
+ +
+
+ +
+ ); + } + + return ( +
+ + Authentication Required + + The service you are trying to access is protected. Please authenticate to continue. + + +
+ {error && } + + {/* SSO Button */} + {methods.oidc && ( + + )} + + {/* Separator */} + {methods.oidc && hasCredentialAuth && } + + {/* Credential Authentication */} + {hasCredentialAuth && ( +
{ + e.preventDefault(); + submitCredentials(activeTab, activeTab === "password" ? password : pin); + }}> + {hasBothCredentials && ( + { + setActiveTab(v as "password" | "pin"); + setTimeout(() => { + if (v === "password") { + passwordRef.current?.focus(); + } else { + pinRef.current?.focus(); + } + }, 0); + }} + > + + + + Password + + + + PIN + + + + )} + +
+ {methods.password && (activeTab === "password" || !methods.pin) && ( + <> + {!hasBothCredentials && } + setPassword(e.target.value)} + /> + + )} + {methods.pin && (activeTab === "pin" || !methods.password) && ( + <> + {!hasBothCredentials && } + + + )} +
+ + +
+ )} +
+
+ + +
+ ); +} + +export default App; diff --git a/proxy/web/src/ErrorPage.tsx b/proxy/web/src/ErrorPage.tsx new file mode 100644 index 000000000..c3120d9a1 --- /dev/null +++ b/proxy/web/src/ErrorPage.tsx @@ -0,0 +1,73 @@ +import { useEffect, useState } from "react"; +import {BookText, RotateCw, Globe, UserIcon, WaypointsIcon} from "lucide-react"; +import { Title } from "@/components/Title"; +import { Description } from "@/components/Description"; +import Button from "@/components/Button"; +import { PoweredByNetBird } from "@/components/PoweredByNetBird"; +import { StatusCard } from "@/components/StatusCard"; +import type { ErrorData } from "@/data"; + +export function ErrorPage({ code, title, message, proxy = true, destination = true, requestId, simple = false, retryUrl }: Readonly) { + useEffect(() => { + document.title = `${title} - NetBird Service`; + }, [title]); + + const [timestamp] = useState(() => new Date().toISOString()); + + return ( +
+ {/* Error Code */} +
+ Error {code} +
+ + {/* Title */} + {title} + + {/* Description */} + {message} + + {/* Status Cards - hidden in simple mode */} + {!simple && ( +
+ + + +
+ )} + + {/* Buttons */} +
+ + +
+ + {/* Request Info */} +
+
+ REQUEST-ID: {requestId} +
+
+ TIMESTAMP: {timestamp} +
+
+ + +
+ ); +} diff --git a/proxy/web/src/assets/favicon.ico b/proxy/web/src/assets/favicon.ico new file mode 100644 index 000000000..50bb80966 Binary files /dev/null and b/proxy/web/src/assets/favicon.ico differ diff --git a/proxy/web/src/assets/fonts/Inter-Italic-VariableFont_opsz,wght.ttf b/proxy/web/src/assets/fonts/Inter-Italic-VariableFont_opsz,wght.ttf new file mode 100644 index 000000000..43ed4f5ee Binary files /dev/null and b/proxy/web/src/assets/fonts/Inter-Italic-VariableFont_opsz,wght.ttf differ diff --git a/proxy/web/src/assets/fonts/Inter-VariableFont_opsz,wght.ttf b/proxy/web/src/assets/fonts/Inter-VariableFont_opsz,wght.ttf new file mode 100644 index 000000000..e31b51e3e Binary files /dev/null and b/proxy/web/src/assets/fonts/Inter-VariableFont_opsz,wght.ttf differ diff --git a/proxy/web/src/assets/netbird-full.svg b/proxy/web/src/assets/netbird-full.svg new file mode 100644 index 000000000..f925d5761 --- /dev/null +++ b/proxy/web/src/assets/netbird-full.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/proxy/web/src/assets/netbird.svg b/proxy/web/src/assets/netbird.svg new file mode 100644 index 000000000..6254931c6 --- /dev/null +++ b/proxy/web/src/assets/netbird.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/proxy/web/src/components/Button.tsx b/proxy/web/src/components/Button.tsx new file mode 100644 index 000000000..aef8496b9 --- /dev/null +++ b/proxy/web/src/components/Button.tsx @@ -0,0 +1,156 @@ +import { cn } from "@/utils/helpers"; +import { forwardRef } from "react"; + +type Variant = + | "default" + | "primary" + | "secondary" + | "secondaryLighter" + | "input" + | "dropdown" + | "dotted" + | "tertiary" + | "white" + | "outline" + | "danger-outline" + | "danger-text" + | "default-outline" + | "danger"; + +type Size = "xs" | "xs2" | "sm" | "md" | "lg"; + +export interface ButtonProps + extends React.ButtonHTMLAttributes { + variant?: Variant; + size?: Size; + rounded?: boolean; + border?: 0 | 1 | 2; + disabled?: boolean; + stopPropagation?: boolean; +} + +const baseStyles = [ + "relative cursor-pointer", + "text-sm focus:z-10 focus:ring-2 font-medium focus:outline-none whitespace-nowrap shadow-sm", + "inline-flex gap-2 items-center justify-center transition-colors focus:ring-offset-1", + "disabled:opacity-40 disabled:cursor-not-allowed disabled:text-nb-gray-300 ring-offset-neutral-950/50", +]; + +const variantStyles: Record = { + default: [ + "bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900", + "dark:focus:ring-zinc-800/50 dark:bg-nb-gray dark:text-gray-400 dark:border-gray-700/30 dark:hover:text-white dark:hover:bg-zinc-800/50", + ], + primary: [ + "dark:focus:ring-netbird-600/50 dark:ring-offset-neutral-950/50 enabled:dark:bg-netbird disabled:dark:bg-nb-gray-910 dark:text-gray-100 enabled:dark:hover:text-white enabled:dark:hover:bg-netbird-500/80", + "enabled:bg-netbird enabled:text-white enabled:focus:ring-netbird-400/50 enabled:hover:bg-netbird-500", + ], + secondary: [ + "bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900", + "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", + "dark:bg-nb-gray-920 dark:text-gray-400 dark:border-gray-700/40 dark:hover:text-white dark:hover:bg-nb-gray-910", + ], + secondaryLighter: [ + "bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900", + "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", + "dark:bg-nb-gray-900/70 dark:text-gray-400 dark:border-gray-700/70 dark:hover:text-white dark:hover:bg-nb-gray-800/60", + ], + input: [ + "bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-neutral-200 text-gray-900", + "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", + "dark:bg-nb-gray-900 dark:text-gray-400 dark:border-nb-gray-700 dark:hover:bg-nb-gray-900/80", + ], + dropdown: [ + "bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-neutral-200 text-gray-900", + "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", + "dark:bg-nb-gray-900/40 dark:text-gray-400 dark:border-nb-gray-900 dark:hover:bg-nb-gray-900/50", + ], + dotted: [ + "bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900 border-dashed", + "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", + "dark:bg-nb-gray-900/30 dark:text-gray-400 dark:border-gray-500/40 dark:hover:text-white dark:hover:bg-zinc-800/50", + ], + tertiary: [ + "bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900", + "dark:focus:ring-zinc-800/50 dark:bg-white dark:text-gray-800 dark:border-gray-700/40 dark:hover:bg-neutral-200 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300", + ], + white: [ + "focus:ring-white/50 bg-white text-gray-800 border-white outline-none hover:bg-neutral-200 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300", + "disabled:dark:bg-nb-gray-900 disabled:dark:text-nb-gray-300 disabled:dark:border-nb-gray-900", + ], + outline: [ + "bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900", + "dark:focus:ring-zinc-800/50 dark:bg-transparent dark:text-netbird dark:border-netbird dark:hover:bg-nb-gray-900/30", + ], + "danger-outline": [ + "enabled:dark:focus:ring-red-800/20 enabled:dark:focus:bg-red-950/40 enabled:hover:dark:bg-red-950/50 enabled:dark:hover:border-red-800/50 dark:bg-transparent dark:text-red-500", + ], + "danger-text": [ + "dark:bg-transparent dark:text-red-500 dark:hover:text-red-600 dark:border-transparent !px-0 !shadow-none !py-0 focus:ring-red-500/30 dark:ring-offset-neutral-950/50", + ], + "default-outline": [ + "dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20", + "dark:bg-transparent dark:text-nb-gray-400 dark:border-transparent dark:hover:text-white dark:hover:bg-nb-gray-900/30 dark:hover:border-nb-gray-800/50", + "data-[state=open]:dark:text-white data-[state=open]:dark:bg-nb-gray-900/30 data-[state=open]:dark:border-nb-gray-800/50", + ], + danger: [ + "dark:focus:ring-red-700/20 dark:focus:bg-red-700 hover:dark:bg-red-700 dark:hover:border-red-800/50 dark:bg-red-600 dark:text-red-100", + ], +}; + +const sizeStyles: Record = { + xs: "text-xs py-2 px-4", + xs2: "text-[0.78rem] py-2 px-4", + sm: "text-sm py-2.5 px-4", + md: "text-sm py-2.5 px-4", + lg: "text-base py-2.5 px-4", +}; + +const borderStyles: Record<0 | 1 | 2, string> = { + 0: "border", + 1: "border border-transparent", + 2: "border border-t-0 border-b-0", +}; + +const Button = forwardRef( + ( + { + variant = "default", + rounded = true, + border = 1, + size = "md", + stopPropagation = true, + className, + onClick, + children, + ...props + }, + ref + ) => { + return ( + + ); + } +); + +Button.displayName = "Button"; + +export default Button; diff --git a/proxy/web/src/components/Card.tsx b/proxy/web/src/components/Card.tsx new file mode 100644 index 000000000..ba92274ac --- /dev/null +++ b/proxy/web/src/components/Card.tsx @@ -0,0 +1,23 @@ +import { cn } from "@/utils/helpers"; +import { GradientFadedBackground } from "@/components/GradientFadedBackground"; + +export const Card = ({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) => { + return ( +
+ + {children} +
+ ); +}; diff --git a/proxy/web/src/components/ConnectionLine.tsx b/proxy/web/src/components/ConnectionLine.tsx new file mode 100644 index 000000000..39080ff6f --- /dev/null +++ b/proxy/web/src/components/ConnectionLine.tsx @@ -0,0 +1,26 @@ +import { X } from "lucide-react"; + +interface ConnectionLineProps { + success?: boolean; +} + +export function ConnectionLine({ success = true }: Readonly) { + if (success) { + return ( +
+
+
+ ); + } + + return ( +
+
+
+
+ +
+
+
+ ); +} diff --git a/proxy/web/src/components/Description.tsx b/proxy/web/src/components/Description.tsx new file mode 100644 index 000000000..60e7ce1cc --- /dev/null +++ b/proxy/web/src/components/Description.tsx @@ -0,0 +1,14 @@ +import { cn } from "@/utils/helpers"; + +type Props = { + children: React.ReactNode; + className?: string; +}; + +export function Description({ children, className }: Readonly) { + return ( +
+ {children} +
+ ); +} \ No newline at end of file diff --git a/proxy/web/src/components/ErrorMessage.tsx b/proxy/web/src/components/ErrorMessage.tsx new file mode 100644 index 000000000..67a66c20f --- /dev/null +++ b/proxy/web/src/components/ErrorMessage.tsx @@ -0,0 +1,7 @@ +export const ErrorMessage = ({ error }: { error?: string }) => { + return ( +
+ {error} +
+ ); +}; diff --git a/proxy/web/src/components/GradientFadedBackground.tsx b/proxy/web/src/components/GradientFadedBackground.tsx new file mode 100644 index 000000000..fc0bdc831 --- /dev/null +++ b/proxy/web/src/components/GradientFadedBackground.tsx @@ -0,0 +1,22 @@ +import { cn } from "@/utils/helpers"; + +type Props = { + className?: string; +}; + +export const GradientFadedBackground = ({ className }: Props) => { + return ( +
+
+
+ ); +}; diff --git a/proxy/web/src/components/HelpText.tsx b/proxy/web/src/components/HelpText.tsx new file mode 100644 index 000000000..ce71bfa6d --- /dev/null +++ b/proxy/web/src/components/HelpText.tsx @@ -0,0 +1,19 @@ +import { cn } from "@/utils/helpers"; + +interface HelpTextProps { + children?: React.ReactNode; + className?: string; +} + +export default function HelpText({ children, className }: Readonly) { + return ( + + {children} + + ); +} diff --git a/proxy/web/src/components/Input.tsx b/proxy/web/src/components/Input.tsx new file mode 100644 index 000000000..7b880ed00 --- /dev/null +++ b/proxy/web/src/components/Input.tsx @@ -0,0 +1,137 @@ +import { cn } from "@/utils/helpers"; +import { Eye, EyeOff } from "lucide-react"; +import * as React from "react"; +import { useState } from "react"; + +export interface InputProps + extends React.InputHTMLAttributes { + customPrefix?: React.ReactNode; + customSuffix?: React.ReactNode; + maxWidthClass?: string; + icon?: React.ReactNode; + error?: string; + prefixClassName?: string; + showPasswordToggle?: boolean; + variant?: "default" | "darker"; +} + +const variantStyles = { + default: [ + "bg-nb-gray-900 placeholder:text-neutral-400/70 border-nb-gray-700", + "ring-offset-neutral-950/50 focus-visible:ring-neutral-500/20", + ], + darker: [ + "bg-nb-gray-920 placeholder:text-neutral-400/70 border-nb-gray-800", + "ring-offset-neutral-950/50 focus-visible:ring-neutral-500/20", + ], + error: [ + "bg-nb-gray-900 placeholder:text-neutral-400/70 border-red-500 text-red-500", + "ring-offset-red-500/10 focus-visible:ring-red-500/10", + ], +}; + +const prefixSuffixStyles = { + default: "bg-nb-gray-900 border-nb-gray-700 text-nb-gray-300", + error: "bg-nb-gray-900 border-red-500 text-nb-gray-300 text-red-500", +}; + +const Input = React.forwardRef( + ( + { + className, + type, + customSuffix, + customPrefix, + icon, + maxWidthClass = "", + error, + variant = "default", + prefixClassName, + showPasswordToggle = false, + ...props + }, + ref + ) => { + const [showPassword, setShowPassword] = useState(false); + const isPasswordType = type === "password"; + const inputType = isPasswordType && showPassword ? "text" : type; + + const passwordToggle = + isPasswordType && showPasswordToggle ? ( + + ) : null; + + const suffix = passwordToggle || customSuffix; + const activeVariant = error ? "error" : variant; + + return ( + <> +
+ {customPrefix && ( +
+ {customPrefix} +
+ )} + +
+ {icon} +
+ + + +
+ {suffix} +
+
+ {error && ( +

{error}

+ )} + + ); + } +); + +Input.displayName = "Input"; + +export { Input }; diff --git a/proxy/web/src/components/Label.tsx b/proxy/web/src/components/Label.tsx new file mode 100644 index 000000000..09e122f8e --- /dev/null +++ b/proxy/web/src/components/Label.tsx @@ -0,0 +1,19 @@ +import { cn } from "@/utils/helpers"; + +type LabelProps = React.LabelHTMLAttributes; + +export function Label({ className, htmlFor, ...props }: Readonly) { + return ( +